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

@ -182,6 +182,8 @@ func main() {
// 本任务的往返预算:在对话页里随时可改
r.Get("/sessions/{id}/budget", handler.GetSessionBudgetHandler)
r.Put("/sessions/{id}/budget", handler.UpdateSessionBudget)
// 本任务的权限档位:在对话页里随时可改
r.Put("/sessions/{id}/permission", handler.UpdateSessionPermission)
// Agent 在正文里提的改名建议:读取与驳回(接受走上面的 PUT alias
r.Get("/sessions/{id}/rename-proposal", handler.GetRenameProposal)
r.Post("/sessions/{id}/rename-proposal/dismiss", handler.DismissRenameProposal)

View File

@ -84,10 +84,17 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
// to_address / agent_name历史事件因此继续工作不需要数据迁移。
{"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"},
{"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"},
// 日历事件已触发的 occurrence。旧库为 NULL等价于「从未触发」
// 日历事件已触发的 occurrence。旧库为 NULL等价于「从未触发」,
// 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的,
// 而反过来(默认成 event_time会让正在等的提醒永远发不出去。
{"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"},
// 日历事件的权限档位plan / workspace / full。事件触发时若新建会话
// 用这一列定死档位;复用已有会话则取「会话现档 与 事件档」中更严那个。
//
// 旧库默认 'workspace':历史事件补发提醒不该静默升到 full提权路径
// 会被 P1 calendar 投递接线堵住,但这里默认值也得守住)。
// 与 sessions.permission_mode 的默认取向一致。
{"calendar_events", "permission_mode", "ALTER TABLE calendar_events ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"},
// 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」,
// 与新建会话的语义一致,不需要数据迁移。
{"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"},

View File

@ -395,6 +395,8 @@ CREATE TABLE IF NOT EXISTS calendar_events (
last_fired_at TIMESTAMPTZ,
-- 已触发的 occurrence= 当时的 event_time。见 init_sqlite.sql 的说明。
fired_for TIMESTAMPTZ,
-- 权限档位plan / workspace / full。见 init_sqlite.sql 的说明。
permission_mode TEXT NOT NULL DEFAULT 'workspace',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(128) NOT NULL DEFAULT ''

View File

@ -433,6 +433,10 @@ CREATE TABLE IF NOT EXISTS calendar_events (
-- 按 occurrence 比相等则精确AdvanceRecurrence 改了 event_time 就再触发,
-- 没改就永不重发。
fired_for DATETIME,
-- 权限档位plan / workspace / full。事件触发时新建会话 → 用此档位定死;
-- 复用已有会话 → 取「会话现档 与 事件档」中更严那个ModeAtMost
-- 不允许通过重复事件提权plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan
permission_mode TEXT NOT NULL DEFAULT 'workspace',
created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),
updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')),

View File

@ -127,12 +127,24 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
subject := forwardSubject(req.Subject, src.Subject)
// 转发按目标地址寻址,不带 reply_to它是一条新线索不该并进原会话
sessionID, _, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
sessionID, _, created, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// 权限档位继承自源会话plan 档派不出 full 档子任务,约束沿链条传递)。
// 只在【新建】目标会话时设:复用既有会话时不改写对方正在遵守的规则。
if created {
mode := repo.InheritedMode(r.Context(), &src.SessionID, models.DefaultPermissionMode)
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
return
}
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
repo.AgentModeEnforcement(r.Context(), to.Name))
}
if isAgent {
// 转发也是一次主动发信,扣【目标会话】的往返预算。
// 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。

View File

@ -278,6 +278,53 @@ type sessionBudgetRequest struct {
Reset bool `json:"reset"`
}
type updatePermissionRequest struct {
// PermissionMode 三档 plan / workspace / full。
//
// 对话页里人可随时改,改了即时生效(不继承、不限「只能同档或更严」——
// 那是 Agent 主动派子任务时的约束;人改档是对一条已有会话的明示意愿,
// 可以从 plan 直接调到 full。脏值 fail-closed 到默认档而不是 full。
PermissionMode string `json:"permission_mode"`
}
// PUT /api/v1/sessions/{id}/permission
//
// 对话页里随时调档位。与 budget 同位置编辑:两者都是任务的属性,
// 人看着往来内容才知道「这件事现在该收紧还是放开」。
//
// 人类可以任改三档(包括从 plan 提到 full —— 人是权限的源头);
// Agent 不经此端点Agent 改档须走发信继承路径,不得自行提权)。
func UpdateSessionPermission(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
var req updatePermissionRequest
if !DecodeBody(w, r, &req) {
return
}
mode := models.NormalizePermissionMode(req.PermissionMode)
if mode == "" {
mode = models.DefaultPermissionMode
}
perm, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to update permission mode")
return
}
// 强制力不在此刷新:它是「平台能力」的事实快照,在会话建立时定死
// (见 repo.SetSessionEnforcement 的注释)。人改档位不改变平台的能力,
// 插件升级才改变 —— 那要等新投递/新会话才会反映。
sse.Default.Broadcast("session_update", map[string]interface{}{
"session_id": sessionID.String(),
"permission_mode": perm.Mode,
"permission_enforcement": perm.Enforcement,
})
JSON(w, http.StatusOK, perm)
}
// GET /api/v1/sessions/{id}/budget
//
// 本会话的往返预算。与 Agent 全局配额是两层,都要过:

View File

@ -53,6 +53,13 @@ type CalendarEvent struct {
Status string `json:"status"` // active/paused/cancelled
LastFiredAt *time.Time `json:"last_fired_at,omitempty"`
// PermissionMode 是事件触发时新建会话应采用的档位plan / workspace / full
// 空 = workspace默认
// 复用已有会话时不能直接搬用:要 ModeAtMost(会话现档, 事件档) ——
// 事件档表示「这件事允许到什么程度」而会话现档可能更严plan 档派出的
// 任务不该因为日程触发就偷偷升到 workspace
PermissionMode string `json:"permission_mode"`
// FiredFor 是已触发的那个 occurrence值 = 当时的 EventTime
// 去重靠它与 EventTime 相等判断,不是拿 LastFiredAt 比大小 ——
// DueEvents 有 60 秒 lookahead后者在窗口内恒为真会导致每 tick 重发。

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

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)
}
}

View File

@ -0,0 +1,369 @@
package repo
import (
"context"
"testing"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// ─── InheritedMode继承、收紧、不存在的父会话 ───
// parent nil → 返回 requested 的规范化值
func TestInheritedMode_NilParent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
got := InheritedMode(ctx, nil, "plan")
if got != models.ModePlan {
t.Errorf("nil parent + plan: got %q, want plan", got)
}
got = InheritedMode(ctx, nil, "workspace")
if got != models.ModeWorkspace {
t.Errorf("nil parent + workspace: got %q, want workspace", got)
}
got = InheritedMode(ctx, nil, "full")
if got != models.ModeFull {
t.Errorf("nil parent + full: got %q, want full", got)
}
}
// parent plan → 子会话只能 plan不能提权
func TestInheritedMode_ParentPlan_Tightens(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id := createTestSession(t, ctx, "pi", "/ws")
perm, err := SetSessionPermissionMode(ctx, id, models.ModePlan)
if err != nil {
t.Fatal(err)
}
if perm.Mode != models.ModePlan {
t.Fatal("expected plan")
}
// 请求 workspace更宽松→ 应被收紧为 plan
got := InheritedMode(ctx, &id, models.ModeWorkspace)
if got != models.ModePlan {
t.Errorf("plan parent + workspace request: got %q, want plan", got)
}
// 请求 full → 同样收紧
got = InheritedMode(ctx, &id, models.ModeFull)
if got != models.ModePlan {
t.Errorf("plan parent + full request: got %q, want plan", got)
}
// 请求 plan → 保持 plan
got = InheritedMode(ctx, &id, models.ModePlan)
if got != models.ModePlan {
t.Errorf("plan parent + plan request: got %q, want plan", got)
}
}
// parent workspace → 子会话 workspace 或更严
func TestInheritedMode_ParentWorkspace(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id := createTestSession(t, ctx, "pi", "/ws")
_, err := SetSessionPermissionMode(ctx, id, models.ModeWorkspace)
if err != nil {
t.Fatal(err)
}
// 请求 full → 收紧为 workspace子不能比父更松
got := InheritedMode(ctx, &id, models.ModeFull)
if got != models.ModeWorkspace {
t.Errorf("workspace parent + full: got %q, want workspace", got)
}
// 请求 plan → 保留 plan比父更严允许
got = InheritedMode(ctx, &id, models.ModePlan)
if got != models.ModePlan {
t.Errorf("workspace parent + plan: got %q, want plan", got)
}
}
// parent full → 子会话可请求任意档位
func TestInheritedMode_ParentFull(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id := createTestSession(t, ctx, "pi", "/ws")
_, err := SetSessionPermissionMode(ctx, id, models.ModeFull)
if err != nil {
t.Fatal(err)
}
got := InheritedMode(ctx, &id, models.ModeWorkspace)
if got != models.ModeWorkspace {
t.Errorf("full parent + workspace: got %q, want workspace", got)
}
got = InheritedMode(ctx, &id, models.ModePlan)
if got != models.ModePlan {
t.Errorf("full parent + plan: got %q, want plan", got)
}
}
// 脏值 fallback非法的 requested 值在 InheritedMode 里被规范化为默认档
func TestInheritedMode_InvalidRequested(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
got := InheritedMode(ctx, nil, "elephant")
if got != models.DefaultPermissionMode {
t.Errorf("invalid requested: got %q, want %q", got, models.DefaultPermissionMode)
}
}
// parent 不存在时查不到回落ModeAtMost(default, req)
func TestInheritedMode_InvalidParent(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
fakeID := uuid.New()
got := InheritedMode(ctx, &fakeID, "full")
want := models.ModeAtMost(models.DefaultPermissionMode, "full")
if got != want {
t.Errorf("invalid parent + full: got %q, want %q (modeAtMost(default, full))", got, want)
}
}
// ─── SetSessionPermissionMode roundtrip + dirty value normalization ───
func TestSetSessionPermissionMode_Roundtrip(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "dsh", 20)
id := createTestSession(t, ctx, "dsh", "/ws")
perm, err := SetSessionPermissionMode(ctx, id, models.ModeFull)
if err != nil {
t.Fatal(err)
}
if perm.Mode != models.ModeFull || perm.Enforcement != "advisory" {
t.Errorf("full mode: got mode=%q enforcement=%q", perm.Mode, perm.Enforcement)
}
// 读出来一致
got := SessionPermissionMode(ctx, id)
if got != models.ModeFull {
t.Errorf("read back: got %q, want full", got)
}
}
func TestSetSessionPermissionMode_DirtyValue_FailClosed(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id := createTestSession(t, ctx, "pi", "/ws")
perm, err := SetSessionPermissionMode(ctx, id, "BOGUS")
if err != nil {
t.Fatal(err)
}
if perm.Mode != models.DefaultPermissionMode {
t.Errorf("dirty value: got %q, want %q (fail-closed to default)", perm.Mode, models.DefaultPermissionMode)
}
}
// ─── 脏值归一化测试(覆盖 NormalizePermissionMode 本身) ───
func TestNormalizePermissionMode_Inputs(t *testing.T) {
tests := []struct {
input string
want string
}{
{"plan", "plan"},
{"workspace", "workspace"},
{"full", "full"},
// 大小写/空白不归一NormalizePermissionMode 只接受精确匹配的合法档位,
// 其余一律 fail-closed 到默认档workspace—— 不 trim 不 lowercase
// 避免「我以为给了 plan 实际拿到别的」这种隐式转换造成的安全错觉。
{"Plan", models.DefaultPermissionMode},
{" PLAN ", models.DefaultPermissionMode},
{"", models.DefaultPermissionMode},
{"bogus", models.DefaultPermissionMode},
{"F ULL", models.DefaultPermissionMode},
}
for _, tt := range tests {
got := models.NormalizePermissionMode(tt.input)
if got != tt.want {
t.Errorf("NormalizePermissionMode(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// ─── 负向对照plan 档不能提权 ───
func TestInheritedMode_PlanCannotEscalate(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
parent := createTestSession(t, ctx, "pi", "/ws")
_, _ = SetSessionPermissionMode(ctx, parent, models.ModePlan)
child := InheritedMode(ctx, &parent, models.ModeFull)
if child != models.ModePlan {
t.Errorf("SECURITY FAIL: plan session escalated to %q via InheritedMode", child)
}
}
// ─── 三层继承链 ───
func TestInheritedMode_ThreeLevelChain(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
root := createTestSession(t, ctx, "pi", "/ws")
_, _ = SetSessionPermissionMode(ctx, root, models.ModeFull)
child := InheritedMode(ctx, &root, models.ModeWorkspace) // workspace < full → workspace
childID := createTestSession(t, ctx, "pi", "/ws")
_, _ = SetSessionPermissionMode(ctx, childID, child)
grandchild := InheritedMode(ctx, &childID, models.ModeFull) // full vs workspace → workspace
if grandchild != models.ModeWorkspace {
t.Errorf("grandchild: got %q, want workspace", grandchild)
}
// plan → workspace → plan chain
planChild := InheritedMode(ctx, &root, models.ModePlan) // plan < full → plan
planChildID := createTestSession(t, ctx, "pi", "/ws")
_, _ = SetSessionPermissionMode(ctx, planChildID, planChild)
grandchild2 := InheritedMode(ctx, &planChildID, models.ModeFull) // full vs plan → plan
if grandchild2 != models.ModePlan {
t.Errorf("plan chain grandchild: got %q, want plan", grandchild2)
}
}
// ─── calendar_events permission_mode roundtrip ───
func TestCalendarEventPermissionMode_Roundtrip(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
// CreateCalendarEvent 应规范化档位
e := &models.CalendarEvent{
Title: "测试日程",
AgentName: "pi",
ToAddress: "pi@/home/program/agentmail",
PermissionMode: "full",
Status: "active",
CreatedBy: "jianf",
}
created, err := CreateCalendarEvent(ctx, e)
if err != nil {
t.Fatal(err)
}
if created.PermissionMode != "full" {
t.Errorf("created event permission_mode: got %q, want full", created.PermissionMode)
}
// 读回来一致
got, err := GetCalendarEvent(ctx, created.EventID)
if err != nil {
t.Fatal(err)
}
if got.PermissionMode != "full" {
t.Errorf("read back: got %q, want full", got.PermissionMode)
}
}
func TestCalendarEventPermissionMode_DirtyValue_Normalized(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
e := &models.CalendarEvent{
Title: "脏值日程",
AgentName: "pi",
ToAddress: "pi@/home/program/agentmail",
PermissionMode: "INVALID",
Status: "active",
CreatedBy: "jianf",
}
created, err := CreateCalendarEvent(ctx, e)
if err != nil {
t.Fatal(err)
}
if created.PermissionMode != models.DefaultPermissionMode {
t.Errorf("dirty value: got %q, want %q", created.PermissionMode, models.DefaultPermissionMode)
}
}
func TestCalendarEventPermissionMode_UpdateRoundtrip(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
e := &models.CalendarEvent{
Title: "更新日程",
AgentName: "pi",
ToAddress: "pi@/home/program/agentmail",
PermissionMode: "workspace",
Status: "active",
CreatedBy: "jianf",
}
created, err := CreateCalendarEvent(ctx, e)
if err != nil {
t.Fatal(err)
}
created.PermissionMode = "plan"
if err := UpdateCalendarEvent(ctx, created.EventID, created); err != nil {
t.Fatal(err)
}
got, err := GetCalendarEvent(ctx, created.EventID)
if err != nil {
t.Fatal(err)
}
if got.PermissionMode != "plan" {
t.Errorf("after update: got %q, want plan", got.PermissionMode)
}
}
// ─── adopt 接管时会话档位必须写入 ───
func TestAdoptPlatformSession_WritesDefaultMode(t *testing.T) {
setupTestDB(t)
ctx := context.Background()
seedAgent(t, "pi", 20)
id, err := AdoptPlatformSession(ctx, "pi", "plat-123", "my-proj", "/ws", "接管测试")
if err != nil {
t.Fatal(err)
}
// 接管会话应显式写入默认档位(不是靠 DB 默认值)
mode := SessionPermissionMode(ctx, id)
if mode != models.DefaultPermissionMode {
t.Errorf("adopt session mode: got %q, want %q", mode, models.DefaultPermissionMode)
}
}
// ─── helpers ───
func createTestSession(t *testing.T, ctx context.Context, agent, workspace string) uuid.UUID {
t.Helper()
id, err := CreateSession(ctx, nil, agent, "test subject", workspace)
if err != nil {
t.Fatalf("create session: %v", err)
}
return id
}
// init 确保每个 test 函数执行前 DB 足够干净
func init() {
// 空 —— setupTestDB 在每个测试函数内调用
}

View File

@ -8,6 +8,7 @@ import (
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
@ -320,6 +321,14 @@ func AdoptPlatformSession(ctx context.Context, agentName, platformID, slug, work
platformID, id); err != nil {
return uuid.Nil, err
}
// 显式写入档位与强制力:接管一条平台会话没有父会话,
// 只靠 DB 默认值会在「schema 列定义变动」或「迁移补列给了不同默认」时
// 静默偏离预期 —— 显式写 'workspace' 是唯一可靠表述「这条会话是新接管的,
// 没有继承来源」的方式。与 me.go 新建会话那条路径一致。
if _, err := SetSessionPermissionMode(ctx, id, models.DefaultPermissionMode); err != nil {
return uuid.Nil, err
}
_ = SetSessionEnforcement(ctx, id, AgentModeEnforcement(ctx, agentName))
// 别名尽量用 slug撞名时 EnsureSessionAlias 自动加后缀
_, _ = EnsureSessionAlias(ctx, id, slug)
return id, nil

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
}
}