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

@ -165,7 +165,7 @@ func fireEvent(ctx context.Context, e models.CalendarEvent) {
// 一起发:首个是主收件人,其余进 cc_list —— 所有人共享同一条线索,
// 能看到彼此的回复。适合「pi 主办、dsh 知情」这种有主次的协作。
primary, cc := recipients[0], recipients[1:]
if err := SendCalendarMail(ctx, e.EventID, primary, subject, body, e.CreatedBy, cc...); err != nil {
if err := SendCalendarMail(ctx, e.EventID, primary, subject, body, e.CreatedBy, e.PermissionMode, cc...); err != nil {
log.Printf("[scheduler] 投递提醒失败(%s → %s +%d抄送): %v",
e.Title, primary, len(cc), err)
} else {
@ -182,7 +182,7 @@ func fireEvent(ctx context.Context, e models.CalendarEvent) {
// 另外两个仍该收到提醒。
ok, failed := 0, 0
for _, addr := range recipients {
if err := SendCalendarMail(ctx, e.EventID, addr, subject, body, e.CreatedBy); err != nil {
if err := SendCalendarMail(ctx, e.EventID, addr, subject, body, e.CreatedBy, e.PermissionMode); err != nil {
failed++
log.Printf("[scheduler] 投递提醒失败(%s → %s): %v", e.Title, addr, err)
continue
@ -231,7 +231,7 @@ func short(id string) string {
// - `agent@/path` → 指定工作目录的默认会话
// - `agent@/path.alias` → 指定已存在的会话(不存在则报错,不静默新建)
// - `agent@/path.new` → 每次提醒开一条新会话(适合互不相关的一次性任务)
func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, createdBy string, ccAddrs ...string) error {
func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, createdBy, permMode string, ccAddrs ...string) error {
addr, err := models.ParseAddress(toAddr)
if err != nil {
return fmt.Errorf("收件地址 %q 无法解析: %w", toAddr, err)
@ -255,7 +255,7 @@ func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, creat
ccList = append(ccList, ca)
}
sessionID, err := resolveCalendarSession(ctx, addr, subject)
sessionID, err := resolveCalendarSession(ctx, addr, subject, permMode)
if err != nil {
return err
}
@ -335,7 +335,27 @@ const calendarSender = "calendar"
//
// 与 handler.resolveTarget 同一套三态语义,但**不受新建会话速率限制**:
// 那条限制是防 Agent 暴开线索的,而日历事件的数量由人在界面上决定。
func resolveCalendarSession(ctx context.Context, addr models.Address, subject string) (uuid.UUID, error) {
//
// 档位规则(见 PLAN 7.11 P1):
// - 新建会话 → 用事件档位定死(permMode,已规范化)
// - 复用已有会话 → ModeAtMost(会话现档, 事件档),取更严,
// 不允许因复用而提权(plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan)
func resolveCalendarSession(ctx context.Context, addr models.Address, subject, permMode string) (uuid.UUID, error) {
// permMode 由调用方已规范化过,这里再保一次(直接调用本函数的路径上该一样)
eventMode := models.NormalizePermissionMode(permMode)
apply := func(sessionID uuid.UUID, created bool) {
if created {
_, _ = repo.SetSessionPermissionMode(ctx, sessionID, eventMode)
_ = repo.SetSessionEnforcement(ctx, sessionID, repo.AgentModeEnforcement(ctx, addr.Name))
return
}
// 复用已有会话:取更严。ModeAtMost 已判过会话现档与事件档,取严的那个。
cur := repo.SessionPermissionMode(ctx, sessionID)
merged := models.ModeAtMost(cur, eventMode)
if merged != cur {
_, _ = repo.SetSessionPermissionMode(ctx, sessionID, merged)
}
}
switch addr.Mode() {
case models.SessionNew:
id, err := repo.CreateSession(ctx, nil, calendarSender, subject, addr.Path)
@ -345,6 +365,7 @@ func resolveCalendarSession(ctx context.Context, addr models.Address, subject st
// 与发信路径一致:`.new` 建完必须立刻有别名,否则这条会话
// 除了回复那一封之外再也无法寻址(未命名会话查不到也补全不出来)。
_, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject))
apply(id, true)
return id, nil
case models.SessionNamed:
@ -358,14 +379,16 @@ func resolveCalendarSession(ctx context.Context, addr models.Address, subject st
return uuid.Nil, err
}
repo.TouchSession(ctx, id)
apply(id, false)
return id, nil
default: // SessionDefault
id, err := repo.FindOrCreateDefaultSession(ctx, addr.Name, addr.Path, calendarSender, subject)
id, created, err := repo.FindOrCreateDefaultSessionCreated(ctx, addr.Name, addr.Path, calendarSender, subject)
if err != nil {
return uuid.Nil, err
}
_, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject))
apply(id, created)
return id, nil
}
}