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

@ -25,7 +25,7 @@ var ErrEventNotFound = errors.New("calendar event not found")
// 加了预算两列没加进 Scan,整个联系人栏 500)。
const calendarCols = `event_id, title, description, reminder_text, agent_name, to_address,
recipients, delivery_mode, event_time, remind_before, recurrence, recurrence_end,
status, last_fired_at, fired_for, created_at, updated_at, created_by`
status, last_fired_at, fired_for, permission_mode, created_at, updated_at, created_by`
// rowScanner 让 QueryRow 与 Rows 共用同一个 scan 实现。
type rowScanner interface {
@ -44,7 +44,8 @@ func scanCalendarEvent(sc rowScanner) (*models.CalendarEvent, error) {
&e.AgentName, &e.ToAddress,
&recipientsJSON, &e.DeliveryMode,
&e.EventTime, &e.RemindBefore, &e.Recurrence, &e.RecurrenceEnd,
&e.Status, &e.LastFiredAt, &e.FiredFor, &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
&e.Status, &e.LastFiredAt, &e.FiredFor, &e.PermissionMode,
&e.CreatedAt, &e.UpdatedAt, &e.CreatedBy,
); err != nil {
return nil, err
}
@ -88,6 +89,10 @@ func CreateCalendarEvent(ctx context.Context, e *models.CalendarEvent) (*models.
if e.DeliveryMode == "" {
e.DeliveryMode = models.DeliverSeparate
}
// 档位合法化:脏值 fail-closed 到默认档,不透传成库里的非法值
//(否则后续读路径会拿到一个 ModeNeedsHuman 判定不了的值)。
e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode)
if e.Recipients == nil {
e.Recipients = []string{}
}
@ -96,14 +101,14 @@ func CreateCalendarEvent(ctx context.Context, e *models.CalendarEvent) (*models.
INSERT INTO calendar_events
(event_id, title, description, reminder_text, agent_name, to_address,
recipients, delivery_mode,
event_time, remind_before, recurrence, recurrence_end, status, created_by,
event_time, remind_before, recurrence, recurrence_end, status, permission_mode, created_by,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.EventID, e.Title, e.Description, e.ReminderText,
e.AgentName, e.ToAddress,
marshalRecipients(e.Recipients), e.DeliveryMode,
e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd,
e.Status, e.CreatedBy, e.CreatedAt, e.UpdatedAt,
e.Status, e.PermissionMode, e.CreatedBy, e.CreatedAt, e.UpdatedAt,
)
if err != nil {
return nil, err
@ -125,19 +130,21 @@ func GetCalendarEvent(ctx context.Context, eventID string) (*models.CalendarEven
func UpdateCalendarEvent(ctx context.Context, eventID string, e *models.CalendarEvent) error {
e.UpdatedAt = time.Now()
// 档位同样在 update 路径上规范化
e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode)
result, err := db.DB.ExecContext(ctx, `
UPDATE calendar_events SET
title = ?, description = ?, reminder_text = ?,
agent_name = ?, to_address = ?,
recipients = ?, delivery_mode = ?,
event_time = ?, remind_before = ?, recurrence = ?, recurrence_end = ?,
status = ?, updated_at = ?
status = ?, permission_mode = ?, updated_at = ?
WHERE event_id = ?`,
e.Title, e.Description, e.ReminderText,
e.AgentName, e.ToAddress,
marshalRecipients(e.Recipients), e.EffectiveDeliveryMode(),
e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd,
e.Status, e.UpdatedAt, eventID,
e.Status, e.PermissionMode, e.UpdatedAt, eventID,
)
if err != nil {
return err