L2: permission mode propagation across forward/calendar/adopt + PUT endpoint + tests

P1 - forward inherits mode:
- doForward: when creating a new target session, use InheritedMode from
  the source session (plan parent → plan child, cannot escalate)
- enforcement snapshot set from receiving agent

P1 - calendar_events gets permission_mode column:
- Added to 3 migration sites (sqlite init, pg init, incremental alter)
- CalendarEvent model gains PermissionMode field
- CreateCalendarEvent / UpdateCalendarEvent normalize + persist the field
- resolveCalendarSession: on new session → write event's mode;
  on reuse → ModeAtMost(cur, eventMode), prevents escalation
  (plan Agent's reminder fires into a workspace session = bypass)
- SendCalendarMail now takes permMode and threads it through
- fireEvent passes event.PermissionMode to all delivery paths

P1 - AdoptPlatformSession explicitly writes default mode:
- Writes DefaultPermissionMode + enforcement on adopt, instead of
  relying on DB column default (avoids silent drift on schema changes)

P2 - PUT /sessions/{id}/permission endpoint:
- New handler UpdateSessionPermission (auth required, access check)
- Registers PUT route alongside existing budget/alias endpoints
- Broadcasts session_update on change
- Does NOT refresh enforcement (design: snapshot at creation)

P3 - Tests (24 new cases):
- permission_mode_test.go: InheritedMode (6 cases), SetSessionPermissionMode
  roundtrip, dirty value fail-closed, NormalizePermissionMode, calendar event
  roundtrip/dirty/update, adopt writes default mode, plan escalation guard,
  3-level inheritance chain
- defaultsession_test.go: budget regression, permission mode regression
  (pins created=false → no reset on reuse)

Deploys with: bash deploy/redeploy-gateway.sh --skip-tests
Schema migration: auto via addMissingColumns (new column default 'workspace')
This commit is contained in:
2026-09-06 17:32:47 +08:00
parent 47fe9a5e74
commit 1dc6223631
12 changed files with 592 additions and 15 deletions

View File

@ -179,3 +179,91 @@ func TestLegacyWrapperStillWorks(t *testing.T) {
t.Fatalf("包装函数应与原行为一致:%s vs %s", id, again)
}
}
// ─── 预算不被冲的回归用例P0 第一项P3 钉死) ───
//
// 守卫原本是 `parentMailID == nil`(表示新建),但省略 session 位复用默认会话时
// parentMailID 也是 nil —— 于是「仅在新建时生效」的字段(预算、档位)在每封
// 省略 session 位的信上都被重写了。实测:第一封 max_rounds=7 → 第二封省略该
// 字段 → 预算被静默改成默认的 20。
//
// 修法handler 改用 FindOrCreateDefaultSessionCreated 返回的 `created` 判据。
// 本测试钉死「复用默认会话时 created=false」这一事实让守卫不会倒退回去。
func TestDefaultSessionReuseBudgetNotReset(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
if err != nil || !created {
t.Fatalf("首封应新建created=%v err=%v", created, err)
}
// 模拟发信路径:新建会话时定预算为 7低于默认 20
if _, err := SetSessionBudget(ctx, first, 7); err != nil {
t.Fatal(err)
}
// 复用的前提是会话里有该收件人参与过的邮件
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
t.Fatal(err)
}
// 第二封省略 session 位 → 复用默认会话created 必须为 false
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
if err != nil {
t.Fatal(err)
}
if second != first {
t.Fatalf("第二封应复用同一会话:%s vs %s", first, second)
}
if created2 {
t.Fatal("复用默认会话时 created 必须为 false否则预算会被默认值冲掉")
}
// 既然 created2=false发信路径不会调 SetSessionBudget → 预算仍为 7
b, err := GetSessionBudget(ctx, first)
if err != nil {
t.Fatal(err)
}
if b.Max != 7 {
t.Errorf("预算被冲掉got max=%dwant 7复用不应重设", b.Max)
}
}
// ─── 档位同样不被冲的回归用例 ───
//
// 与预算同理:复用默认会话时档位也不该被重设成默认档。
// 人指定 plan 档后,第二封信省略 session 位复用同一条会话 →
// created=false → SetSessionPermissionMode 不被调 → 档位仍为 plan。
func TestDefaultSessionReusePermissionModeNotReset(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封")
if err != nil || !created {
t.Fatalf("首封应新建created=%v err=%v", created, err)
}
// 新建会话时定档位为 plan比默认 workspace 更严)
if _, err := SetSessionPermissionMode(ctx, first, "plan"); err != nil {
t.Fatal(err)
}
if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil {
t.Fatal(err)
}
second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封")
if err != nil {
t.Fatal(err)
}
if second != first {
t.Fatalf("第二封应复用同一会话:%s vs %s", first, second)
}
if created2 {
t.Fatal("复用默认会话时 created 必须为 false否则档位会被默认值冲掉")
}
mode := SessionPermissionMode(ctx, first)
if mode != "plan" {
t.Errorf("档位被冲掉got %qwant plan复用不应重设", mode)
}
}