agent-relay-server 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/docs/openapi.json +103 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-ckJ2VVgq.js +3 -0
  4. package/public/assets/MessageBubble-ckJ2VVgq.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-o3u36Del.js → PlanGraphPanel-r3aVT4uc.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-o3u36Del.js.map → PlanGraphPanel-r3aVT4uc.js.map} +1 -1
  7. package/public/assets/{activity-L3POfhGa.js → activity-DtMjjws8.js} +2 -2
  8. package/public/assets/{activity-L3POfhGa.js.map → activity-DtMjjws8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CeoqyRis.js → agent-profiles-eRlq98tp.js} +2 -2
  10. package/public/assets/{agent-profiles-CeoqyRis.js.map → agent-profiles-eRlq98tp.js.map} +1 -1
  11. package/public/assets/agents-DmbjRTA7.js +2 -0
  12. package/public/assets/{agents-DAdDtQb6.js.map → agents-DmbjRTA7.js.map} +1 -1
  13. package/public/assets/{analytics-B6wGSTf3.js → analytics-BW7ik_Ws.js} +2 -2
  14. package/public/assets/{analytics-B6wGSTf3.js.map → analytics-BW7ik_Ws.js.map} +1 -1
  15. package/public/assets/{automation-kakk3xZ9.js → automation-CxwGXWC7.js} +2 -2
  16. package/public/assets/{automation-kakk3xZ9.js.map → automation-CxwGXWC7.js.map} +1 -1
  17. package/public/assets/{badge-DGB2nVM0.js → badge-BwaoBnNO.js} +2 -2
  18. package/public/assets/{badge-DGB2nVM0.js.map → badge-BwaoBnNO.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-BSwkl9A8.js → branch-state-badge-CvdKPTlV.js} +2 -2
  20. package/public/assets/{branch-state-badge-BSwkl9A8.js.map → branch-state-badge-CvdKPTlV.js.map} +1 -1
  21. package/public/assets/{button-D_Y8CEP4.js → button-CFIklTDX.js} +2 -2
  22. package/public/assets/{button-D_Y8CEP4.js.map → button-CFIklTDX.js.map} +1 -1
  23. package/public/assets/{card-DTLNgk0R.js → card-Bi94xEmr.js} +2 -2
  24. package/public/assets/{card-DTLNgk0R.js.map → card-Bi94xEmr.js.map} +1 -1
  25. package/public/assets/{channels-BzY-_4Zi.js → channels-D8UY3baa.js} +2 -2
  26. package/public/assets/{channels-BzY-_4Zi.js.map → channels-D8UY3baa.js.map} +1 -1
  27. package/public/assets/chat-CCPukbI9.js +2 -0
  28. package/public/assets/chat-CCPukbI9.js.map +1 -0
  29. package/public/assets/{connectors-empwp8UF.js → connectors-W2MslhQ2.js} +2 -2
  30. package/public/assets/{connectors-empwp8UF.js.map → connectors-W2MslhQ2.js.map} +1 -1
  31. package/public/assets/{copy-button-BpgTft5S.js → copy-button-A0pXLXBy.js} +2 -2
  32. package/public/assets/{copy-button-BpgTft5S.js.map → copy-button-A0pXLXBy.js.map} +1 -1
  33. package/public/assets/display-CWMHll1J.js +3 -0
  34. package/public/assets/display-CWMHll1J.js.map +1 -0
  35. package/public/assets/{dist-DetIq2Rn.js → dist--NYpzm4f.js} +2 -2
  36. package/public/assets/{dist-DetIq2Rn.js.map → dist--NYpzm4f.js.map} +1 -1
  37. package/public/assets/{dist-dffB6laV.js → dist-BODiHbq1.js} +2 -2
  38. package/public/assets/{dist-dffB6laV.js.map → dist-BODiHbq1.js.map} +1 -1
  39. package/public/assets/{dist-B7m1nK9-.js → dist-C4VoXCT-.js} +2 -2
  40. package/public/assets/{dist-B7m1nK9-.js.map → dist-C4VoXCT-.js.map} +1 -1
  41. package/public/assets/dist-D3jA_LvQ.js +2 -0
  42. package/public/assets/{dist-CoqsUXVF.js.map → dist-D3jA_LvQ.js.map} +1 -1
  43. package/public/assets/{dist-C9UjQOV6.js → dist-DVorlUcA.js} +2 -2
  44. package/public/assets/{dist-C9UjQOV6.js.map → dist-DVorlUcA.js.map} +1 -1
  45. package/public/assets/{dist-CrI1Uij-.js → dist-DY3KCMIN.js} +2 -2
  46. package/public/assets/{dist-CrI1Uij-.js.map → dist-DY3KCMIN.js.map} +1 -1
  47. package/public/assets/{dist-CyQcAgI5.js → dist-DhEz6YNy.js} +2 -2
  48. package/public/assets/{dist-CyQcAgI5.js.map → dist-DhEz6YNy.js.map} +1 -1
  49. package/public/assets/dist-HTxIFS7t.js +2 -0
  50. package/public/assets/{dist-DHiVCawj.js.map → dist-HTxIFS7t.js.map} +1 -1
  51. package/public/assets/{dist-CzelKhUn.js → dist-_EunAAFX.js} +2 -2
  52. package/public/assets/{dist-CzelKhUn.js.map → dist-_EunAAFX.js.map} +1 -1
  53. package/public/assets/dist-rLCXIl7x.js +2 -0
  54. package/public/assets/{dist-DRTRN4My.js.map → dist-rLCXIl7x.js.map} +1 -1
  55. package/public/assets/{dist-BvF_x64R.js → dist-yzKYLD10.js} +2 -2
  56. package/public/assets/{dist-BvF_x64R.js.map → dist-yzKYLD10.js.map} +1 -1
  57. package/public/assets/{es2015-CBZid1bm.js → es2015-BDZowz0r.js} +2 -2
  58. package/public/assets/{es2015-CBZid1bm.js.map → es2015-BDZowz0r.js.map} +1 -1
  59. package/public/assets/{formatted-body-impl-CEqSWNRL.js → formatted-body-impl-tIjgf9Kr.js} +2 -2
  60. package/public/assets/{formatted-body-impl-CEqSWNRL.js.map → formatted-body-impl-tIjgf9Kr.js.map} +1 -1
  61. package/public/assets/index-BfQFX8td.js +22 -0
  62. package/public/assets/{index-CtGaz-Yg.js.map → index-BfQFX8td.js.map} +1 -1
  63. package/public/assets/index-D8vc380x.css +2 -0
  64. package/public/assets/{input-C5FrEDmc.js → input-CD-ZcZIA.js} +2 -2
  65. package/public/assets/{input-C5FrEDmc.js.map → input-CD-ZcZIA.js.map} +1 -1
  66. package/public/assets/insights-B93PGmKq.js +2 -0
  67. package/public/assets/{insights-mf5sd483.js.map → insights-B93PGmKq.js.map} +1 -1
  68. package/public/assets/{integrations-4V9lOQ8b.js → integrations-BfWMAznM.js} +2 -2
  69. package/public/assets/{integrations-4V9lOQ8b.js.map → integrations-BfWMAznM.js.map} +1 -1
  70. package/public/assets/{lib-DjewNYeR.js → lib-BqL6WCDu.js} +2 -2
  71. package/public/assets/{lib-DjewNYeR.js.map → lib-BqL6WCDu.js.map} +1 -1
  72. package/public/assets/lucide-react-D78N6SwK.js +9 -0
  73. package/public/assets/lucide-react-D78N6SwK.js.map +1 -0
  74. package/public/assets/{maintenance-CruMfiA7.js → maintenance-CgRASTD0.js} +2 -2
  75. package/public/assets/{maintenance-CruMfiA7.js.map → maintenance-CgRASTD0.js.map} +1 -1
  76. package/public/assets/{managed-agents-fOhHGzNI.js → managed-agents-B1AdrEv4.js} +2 -2
  77. package/public/assets/{managed-agents-fOhHGzNI.js.map → managed-agents-B1AdrEv4.js.map} +1 -1
  78. package/public/assets/{markdown-preview-impl-g5-PTM1A.js → markdown-preview-impl-D2zEt-i-.js} +2 -2
  79. package/public/assets/{markdown-preview-impl-g5-PTM1A.js.map → markdown-preview-impl-D2zEt-i-.js.map} +1 -1
  80. package/public/assets/memory-nUBcij_9.js +2 -0
  81. package/public/assets/{memory-CRyq2WNC.js.map → memory-nUBcij_9.js.map} +1 -1
  82. package/public/assets/{messages-BqLp0X3y.js → messages-BNDs53bW.js} +2 -2
  83. package/public/assets/{messages-BqLp0X3y.js.map → messages-BNDs53bW.js.map} +1 -1
  84. package/public/assets/{orchestrators-BD4nZcG3.js → orchestrators-BouoI90a.js} +2 -2
  85. package/public/assets/{orchestrators-BD4nZcG3.js.map → orchestrators-BouoI90a.js.map} +1 -1
  86. package/public/assets/{overview-De-2GN06.js → overview-C8_hAGpC.js} +2 -2
  87. package/public/assets/{overview-De-2GN06.js.map → overview-C8_hAGpC.js.map} +1 -1
  88. package/public/assets/{pairs-DOqjBd0R.js → pairs-BcCOB1oO.js} +2 -2
  89. package/public/assets/{pairs-DOqjBd0R.js.map → pairs-BcCOB1oO.js.map} +1 -1
  90. package/public/assets/projects-MZ6CNrYO.js +2 -0
  91. package/public/assets/{projects-Byp_gLoD.js.map → projects-MZ6CNrYO.js.map} +1 -1
  92. package/public/assets/{react-dom--5xRjaey.js → react-dom-CU0WCHqq.js} +2 -2
  93. package/public/assets/{react-dom--5xRjaey.js.map → react-dom-CU0WCHqq.js.map} +1 -1
  94. package/public/assets/{security-DtoRSpIi.js → security-Bh8BGpZR.js} +2 -2
  95. package/public/assets/{security-DtoRSpIi.js.map → security-Bh8BGpZR.js.map} +1 -1
  96. package/public/assets/{select-esP750zQ.js → select-Drdvachg.js} +2 -2
  97. package/public/assets/{select-esP750zQ.js.map → select-Drdvachg.js.map} +1 -1
  98. package/public/assets/settings-IrNBT0hy.js +4 -0
  99. package/public/assets/{settings-U8788lYt.js.map → settings-IrNBT0hy.js.map} +1 -1
  100. package/public/assets/store-CqFZmc-B.js +9 -0
  101. package/public/assets/store-CqFZmc-B.js.map +1 -0
  102. package/public/assets/{tasks-CtiwQy9I.js → tasks-heEgx7vT.js} +2 -2
  103. package/public/assets/{tasks-CtiwQy9I.js.map → tasks-heEgx7vT.js.map} +1 -1
  104. package/public/assets/teams-BjETr-_W.js +2 -0
  105. package/public/assets/teams-BjETr-_W.js.map +1 -0
  106. package/public/assets/{terminal-viewer-impl-h9hvvB9S.js → terminal-viewer-impl-BDqmbWB3.js} +3 -3
  107. package/public/assets/{terminal-viewer-impl-h9hvvB9S.js.map → terminal-viewer-impl-BDqmbWB3.js.map} +1 -1
  108. package/public/assets/{use-keyboard-viewport-vC7NeLD9.js → use-keyboard-viewport-CichJ8Jn.js} +2 -2
  109. package/public/assets/{use-keyboard-viewport-vC7NeLD9.js.map → use-keyboard-viewport-CichJ8Jn.js.map} +1 -1
  110. package/public/assets/{work-queue-C-bS6YmI.js → work-queue-rgQs4Wis.js} +2 -2
  111. package/public/assets/{work-queue-C-bS6YmI.js.map → work-queue-rgQs4Wis.js.map} +1 -1
  112. package/public/assets/workspaces-hIjZicaF.js +3 -0
  113. package/public/assets/{workspaces-Bivhqw2N.js.map → workspaces-hIjZicaF.js.map} +1 -1
  114. package/public/index.html +22 -22
  115. package/src/agent-authz.ts +169 -0
  116. package/src/agent-ref.ts +7 -3
  117. package/src/dashboard-presence.ts +91 -0
  118. package/src/db/agent-ownership.ts +168 -0
  119. package/src/db/index.ts +2 -0
  120. package/src/db/migrations.ts +17 -0
  121. package/src/db/schema.ts +16 -0
  122. package/src/db/teams.ts +13 -4
  123. package/src/db/users.ts +120 -0
  124. package/src/maintenance/teams.ts +1 -7
  125. package/src/routes/_shared.ts +75 -1
  126. package/src/routes/agent-sessions.ts +57 -6
  127. package/src/routes/agents.ts +13 -4
  128. package/src/routes/index.ts +6 -1
  129. package/src/routes/settings.ts +33 -8
  130. package/src/routes/teams.ts +11 -4
  131. package/src/routes/users.ts +8 -0
  132. package/src/services/register-agent.ts +11 -1
  133. package/src/services/send-message.ts +7 -1
  134. package/src/services/team.ts +17 -4
  135. package/src/services/transient-agent-reaper.ts +36 -0
  136. package/src/settings/registry.ts +63 -0
  137. package/src/sse.ts +7 -0
  138. package/src/team-idle-tracker.ts +13 -0
  139. package/src/user-settings-store.ts +86 -0
  140. package/public/assets/MessageBubble-C-3IcqgU.js +0 -3
  141. package/public/assets/MessageBubble-C-3IcqgU.js.map +0 -1
  142. package/public/assets/agents-DAdDtQb6.js +0 -2
  143. package/public/assets/chat-DDlRjwGC.js +0 -2
  144. package/public/assets/chat-DDlRjwGC.js.map +0 -1
  145. package/public/assets/display-DHgdn4vN.js +0 -3
  146. package/public/assets/display-DHgdn4vN.js.map +0 -1
  147. package/public/assets/dist-CoqsUXVF.js +0 -2
  148. package/public/assets/dist-DHiVCawj.js +0 -2
  149. package/public/assets/dist-DRTRN4My.js +0 -2
  150. package/public/assets/index-BpsfMZ3T.css +0 -2
  151. package/public/assets/index-CtGaz-Yg.js +0 -22
  152. package/public/assets/insights-mf5sd483.js +0 -2
  153. package/public/assets/lucide-react-CNGB8fo1.js +0 -9
  154. package/public/assets/lucide-react-CNGB8fo1.js.map +0 -1
  155. package/public/assets/memory-CRyq2WNC.js +0 -2
  156. package/public/assets/projects-Byp_gLoD.js +0 -2
  157. package/public/assets/settings-U8788lYt.js +0 -4
  158. package/public/assets/store-Cjcd0egy.js +0 -9
  159. package/public/assets/store-Cjcd0egy.js.map +0 -1
  160. package/public/assets/teams-Cx29VUif.js +0 -2
  161. package/public/assets/teams-Cx29VUif.js.map +0 -1
  162. package/public/assets/workspaces-Bivhqw2N.js +0 -3
@@ -0,0 +1,120 @@
1
+ // First-class human users (#388 keystone #393). The relational identity store behind the
2
+ // per-user `user:<id>` addressing namespace. Addressing/alias logic lives in the SDK
3
+ // (agent-relay-sdk `users.ts`, the single resolver home); this module owns persistence.
4
+ import { DEFAULT_USER_ID } from "agent-relay-sdk";
5
+ import { ValidationError, getDb } from "./connection.ts";
6
+ import type { CreateUserInput, User, UserRole, UserStatus } from "../types";
7
+
8
+ interface UserRow {
9
+ id: string;
10
+ handle: string;
11
+ display_name: string;
12
+ role: string;
13
+ status: string;
14
+ tenant_id: string | null;
15
+ avatar_artifact_id: string | null;
16
+ created_at: number;
17
+ updated_at: number;
18
+ }
19
+
20
+ /**
21
+ * Single DDL home for the `users` table (#388 keystone #393). Called from initDb (fresh DBs)
22
+ * and applyMigrations (existing DBs gain it on upgrade), mirroring the
23
+ * `ensureContinuationArchiveSearchSchema` pattern. `tenant_id` is the SaaS multi-tenant seam
24
+ * (nullable, defaulted — multi-tenant becomes an additive migration, not a refactor);
25
+ * `avatar_artifact_id` is the column-only avatar FK toward the artifact stack (#395/#630).
26
+ */
27
+ export function ensureUsersSchema(): void {
28
+ getDb().run(`
29
+ CREATE TABLE IF NOT EXISTS users (
30
+ id TEXT PRIMARY KEY,
31
+ handle TEXT NOT NULL,
32
+ display_name TEXT NOT NULL,
33
+ role TEXT NOT NULL DEFAULT 'operator',
34
+ status TEXT NOT NULL DEFAULT 'active',
35
+ tenant_id TEXT DEFAULT 'default',
36
+ avatar_artifact_id TEXT REFERENCES artifacts(id) ON DELETE SET NULL,
37
+ created_at INTEGER NOT NULL,
38
+ updated_at INTEGER NOT NULL
39
+ )
40
+ `);
41
+ getDb().run("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(coalesce(tenant_id, ''), handle)");
42
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id)");
43
+ }
44
+
45
+ function rowToUser(row: UserRow): User {
46
+ return {
47
+ id: row.id,
48
+ handle: row.handle,
49
+ displayName: row.display_name,
50
+ role: row.role as UserRole,
51
+ status: row.status as UserStatus,
52
+ tenantId: row.tenant_id,
53
+ avatarArtifactId: row.avatar_artifact_id,
54
+ createdAt: row.created_at,
55
+ updatedAt: row.updated_at,
56
+ };
57
+ }
58
+
59
+ export function getUser(id: string): User | undefined {
60
+ const row = getDb().query("SELECT * FROM users WHERE id = ?").get(id) as UserRow | null;
61
+ return row ? rowToUser(row) : undefined;
62
+ }
63
+
64
+ export function getUserByHandle(handle: string, tenantId: string | null = "default"): User | undefined {
65
+ const row = getDb()
66
+ .query("SELECT * FROM users WHERE handle = ? AND coalesce(tenant_id, '') = coalesce(?, '')")
67
+ .get(handle, tenantId) as UserRow | null;
68
+ return row ? rowToUser(row) : undefined;
69
+ }
70
+
71
+ export function listUsers(): User[] {
72
+ const rows = getDb().query("SELECT * FROM users ORDER BY created_at").all() as UserRow[];
73
+ return rows.map(rowToUser);
74
+ }
75
+
76
+ export function createUser(input: CreateUserInput): User {
77
+ if (!input.id) throw new ValidationError("user id is required");
78
+ if (!input.handle) throw new ValidationError("user handle is required");
79
+ const now = Date.now();
80
+ getDb()
81
+ .query(
82
+ `INSERT INTO users (id, handle, display_name, role, status, tenant_id, avatar_artifact_id, created_at, updated_at)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
84
+ )
85
+ .run(
86
+ input.id,
87
+ input.handle,
88
+ input.displayName || input.handle,
89
+ input.role ?? "operator",
90
+ input.status ?? "active",
91
+ input.tenantId === undefined ? "default" : input.tenantId,
92
+ input.avatarArtifactId ?? null,
93
+ now,
94
+ now,
95
+ );
96
+ const created = getUser(input.id);
97
+ if (!created) throw new ValidationError(`failed to create user ${input.id}`);
98
+ return created;
99
+ }
100
+
101
+ /**
102
+ * Seed the admin-level default user mapped to the existing single human (#393 P0).
103
+ * Idempotent (ON CONFLICT DO NOTHING) so it runs safely on every boot/migration — the
104
+ * existing human is NEVER locked out, and the relational identity all later #388 waves FK to
105
+ * (`users.id='default'`, role=admin, tenant_id='default') exists from day one. Bare `'user'`
106
+ * and `user:default` both resolve to this row (see the SDK resolver helpers).
107
+ */
108
+ export function seedDefaultUser(): User {
109
+ const now = Date.now();
110
+ getDb()
111
+ .query(
112
+ `INSERT INTO users (id, handle, display_name, role, status, tenant_id, avatar_artifact_id, created_at, updated_at)
113
+ VALUES (?, 'admin', 'User', 'admin', 'active', 'default', NULL, ?, ?)
114
+ ON CONFLICT(id) DO NOTHING`,
115
+ )
116
+ .run(DEFAULT_USER_ID, now, now);
117
+ const seeded = getUser(DEFAULT_USER_ID);
118
+ if (!seeded) throw new ValidationError("failed to seed default user");
119
+ return seeded;
120
+ }
@@ -13,13 +13,7 @@ import { dissolveTeamById } from "../services/team";
13
13
  import { getTeamsConfig } from "../teams-config-store";
14
14
  import { teamReapGraceMs } from "./constants";
15
15
  import { notifyTarget } from "./events";
16
-
17
- interface TeamIdleTrackerEntry {
18
- firstNoBusyAt: number;
19
- pinged: boolean;
20
- }
21
-
22
- const teamIdleTracker = new Map<string, TeamIdleTrackerEntry>();
16
+ import { teamIdleTracker } from "../team-idle-tracker";
23
17
 
24
18
  export function resetTeamIdleWatchdogForTests(): void {
25
19
  teamIdleTracker.clear();
@@ -5,7 +5,8 @@ import { ValidationError, createActivityEvent, createCallbackDelivery, finishCal
5
5
  import { cleanEpoch, cleanString, optionalEnum } from "../validation";
6
6
  import { emitActivityEvent, emitMessageQueued, emitNewMessage } from "../sse";
7
7
  import { emitRelayEvent } from "../events";
8
- import { errMessage, isRecord } from "agent-relay-sdk";
8
+ import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isRecord, userSinkId } from "agent-relay-sdk";
9
+ import { authorizeAgentForUser, visibleAgentIdsForUser, type AgentAction } from "../agent-authz";
9
10
  import { generateSpawnRequestId } from "../spawn-command";
10
11
  import { getComponentAuth, getIntegrationAuth, isAuthorized, isRequestAuthorizedFor } from "../security";
11
12
  import { readBodyBytes } from "../http-body";
@@ -87,6 +88,79 @@ export function dashboardAttribution(req: Request, surface?: unknown): Record<st
87
88
  return { surface: out };
88
89
  }
89
90
 
91
+ type RequestUserResolver = (req: Request) => string;
92
+
93
+ // The active resolver behind requestUserId. The default returns the seeded admin
94
+ // (`DEFAULT_USER_ID`) and NEVER reads a client header, so the relay stays single-user until
95
+ // auth/sessions (#389) install a real session→users.id resolver via setRequestUserResolver.
96
+ const defaultRequestUserResolver: RequestUserResolver = () => DEFAULT_USER_ID;
97
+ let requestUserResolver: RequestUserResolver = defaultRequestUserResolver;
98
+
99
+ /**
100
+ * The #389 wiring point: install the authenticated-session → `users.id` resolver. ONE call here
101
+ * turns on per-user enforcement + attribution across every route — no route edits — because all
102
+ * three seams already funnel through {@link requestUserId}. Also the seam tests use to exercise
103
+ * the multi-user gates that #389 will switch on (no client header is ever trusted).
104
+ */
105
+ export function setRequestUserResolver(resolver: RequestUserResolver): void {
106
+ requestUserResolver = resolver;
107
+ }
108
+
109
+ /** Restore the default single-user resolver (test teardown / #389 rollback). */
110
+ export function resetRequestUserResolver(): void {
111
+ requestUserResolver = defaultRequestUserResolver;
112
+ }
113
+
114
+ /**
115
+ * THE canonical server-side resolver for "which human is acting in this request" (#388 Wave A
116
+ * integration), returning the relational `users.id`. Settings (#391), authz (#392), and
117
+ * attribution/presence (#390) ALL resolve through this one path, so the three "who is the user"
118
+ * seams can never disagree or drift into different id-spaces.
119
+ *
120
+ * It deliberately does NOT trust the additive `X-Dashboard-User-Id` header. That header is a
121
+ * dormant #389 seam — auth/sessions will wire it to the *authenticated* session; today it is
122
+ * trusted by NOTHING, so a crafted header can forge neither attribution, presence, settings,
123
+ * nor an authz decision. Until #389 calls {@link setRequestUserResolver} this resolves to the
124
+ * seeded admin `DEFAULT_USER_ID` ('default'): authz fail-opens (single-user world), settings
125
+ * read the lone human's row, and attribution stamps the legacy `user` sink — byte-for-byte the
126
+ * pre-Wave-A behavior.
127
+ */
128
+ export function requestUserId(req: Request): string {
129
+ return requestUserResolver(req);
130
+ }
131
+
132
+ /**
133
+ * The canonical human MESSAGE ADDRESS (`user` / `user:<id>`) for the acting user, derived from
134
+ * {@link requestUserId} via the SDK resolver (never a hand-rolled namespace). Use ONLY where
135
+ * message routing/attribution genuinely needs an address — a message `from`, an audit
136
+ * `requestedByUser`, presence — while authz/settings/ownership use the relational
137
+ * {@link requestUserId}. For the default user this collapses to the legacy bare `user` sink, so
138
+ * existing message/operator/presence rows match byte-for-byte.
139
+ */
140
+ export function requestUserAddress(req: Request): string {
141
+ return canonicalHumanAgentId(userSinkId(requestUserId(req)));
142
+ }
143
+
144
+ /**
145
+ * Gate a per-agent route on the acting user's authorization (#392, wired by Wave-A integration).
146
+ * Returns a 403 when the user may not perform `action` on the agent, else null to proceed.
147
+ * Single-user worlds fail-open inside {@link authorizeAgentForUser}, so existing routes are
148
+ * unchanged until #389 upgrades {@link requestUserId} to the real session user.
149
+ */
150
+ export function authorizeAgentRoute(req: Request, agentId: string, action: AgentAction): Response | null {
151
+ return authorizeAgentForUser(requestUserId(req), agentId, action) ? null : error("forbidden", 403);
152
+ }
153
+
154
+ /** True iff the acting user may observe `agentId` (drives detail/chat-read existence-hiding). */
155
+ export function canViewAgentRoute(req: Request, agentId: string): boolean {
156
+ return authorizeAgentForUser(requestUserId(req), agentId, "view");
157
+ }
158
+
159
+ /** Filter `agentIds` to the subset the acting user may observe, preserving input order. */
160
+ export function visibleAgentIdsForRequest(req: Request, agentIds: readonly string[]): Set<string> {
161
+ return new Set(visibleAgentIdsForUser(requestUserId(req), agentIds));
162
+ }
163
+
90
164
  type ParseBodyResult<T> =
91
165
  | { ok: true; body: T | null }
92
166
  | { ok: false; status: number; error: string };
@@ -1,12 +1,13 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agent-sessions.
2
2
  import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
3
- import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
3
+ import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
5
+ import { announcePresence, viewersForAgent } from "../dashboard-presence";
5
6
  import { buildManagedSpawnParams } from "../managed-policy";
6
7
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
7
8
  import { cleanString, optionalEnum } from "../validation";
8
9
  import { createCommand } from "../commands-db";
9
- import { emitAgentStatus, emitNewMessage } from "../sse";
10
+ import { emitAgentStatus, emitDashboardPresence, emitNewMessage } from "../sse";
10
11
  import { getAgentProfile, getSpawnPolicy } from "../config-store";
11
12
  import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
12
13
  import { runnerRuntimeTokenEnv } from "../runtime-tokens";
@@ -208,6 +209,10 @@ export const postAgentAction: Handler = async (req, params) => {
208
209
  if (!action) return error("action required");
209
210
  const agent = getAgent(params.id!);
210
211
  if (!agent) return error("agent not found", 404);
212
+ // #392: the acting user must be allowed to drive this agent (fail-open single-user). Gated
213
+ // before the capability probe so an unauthorized user learns nothing about what the agent supports.
214
+ const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
215
+ if (driveDenied) return driveDenied;
211
216
  if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
212
217
 
213
218
  // Shutdown converges on the shutdownAgent service (epic #342, #347): one #221
@@ -273,6 +278,7 @@ export const postAgentAction: Handler = async (req, params) => {
273
278
  ...(action === "compact" ? compactMemoryParams(agent) : {}),
274
279
  ...(resumeId ? { claudeResumeId: resumeId } : {}),
275
280
  requestedBy: "dashboard",
281
+ requestedByUser: requestUserAddress(req),
276
282
  requestedAt: Date.now(),
277
283
  },
278
284
  });
@@ -292,7 +298,7 @@ export const postAgentAction: Handler = async (req, params) => {
292
298
  icon: agentControlActionIcon(action),
293
299
  view: "agents",
294
300
  agentId: agent.id,
295
- metadata: { action, commandId: command.id, ...(orchestrator ? { orchestratorId: orchestrator.id } : {}), ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
301
+ metadata: { action, commandId: command.id, requestedByUser: requestUserAddress(req), ...(orchestrator ? { orchestratorId: orchestrator.id } : {}), ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
296
302
  });
297
303
  return json({ ok: true, action, command }, 202);
298
304
  } catch (e) {
@@ -310,14 +316,22 @@ export const postAgentPrompt: Handler = async (req, params) => {
310
316
  const attachments = cleanAttachmentRefs(parsed.body.attachments ?? parsed.body.artifacts, "attachments");
311
317
  const agent = getAgent(params.id!);
312
318
  if (!agent) return error("agent not found", 404);
319
+ // #392: only a user authorized to drive this agent may prompt it (fail-open single-user).
320
+ const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
321
+ if (driveDenied) return driveDenied;
313
322
  if (agent.status === "offline") return error("agent is offline", 422);
314
323
  const denied = authorizeRoute(req, {
315
324
  scope: "message:send",
316
325
  resource: { target: agent.id, agentId: "user" },
317
326
  });
318
327
  if (denied) return denied;
328
+ // Attribute the prompt to the acting human, derived from the TRUSTED requestUserId — never
329
+ // an echo of the client `X-Dashboard-User-Id` header (#390 forgery fix). Defaults to the
330
+ // canonical `user` sink, so today's single operator routes/renders exactly as before; once
331
+ // auth (#389) sets the real session user, the bubble carries the real `user:<id>`.
332
+ const fromUser = requestUserAddress(req);
319
333
  const message = sendMessage({
320
- from: "user",
334
+ from: fromUser,
321
335
  to: agent.id,
322
336
  kind: "session",
323
337
  body,
@@ -336,7 +350,7 @@ export const postAgentPrompt: Handler = async (req, params) => {
336
350
  kind: "message",
337
351
  title: "Prompt injected",
338
352
  body,
339
- meta: `user -> ${agent.id}`,
353
+ meta: `${fromUser} -> ${agent.id}`,
340
354
  icon: "ti-message-bolt",
341
355
  view: "messages",
342
356
  messageId: message.id,
@@ -371,6 +385,9 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
371
385
  }
372
386
  const agent = getAgent(params.id!);
373
387
  if (!agent) return error("agent not found", 404);
388
+ // #392: approving/denying a permission is driving the agent (fail-open single-user).
389
+ const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
390
+ if (driveDenied) return driveDenied;
374
391
  if (!agentIsControlEligible(agent)) return error("agent cannot receive permission decisions", 400);
375
392
  const pendingApproval = isRecord(agent.meta?.providerState) && isRecord(agent.meta.providerState.pendingApproval)
376
393
  ? agent.meta.providerState.pendingApproval
@@ -397,6 +414,7 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
397
414
  ...(reason ? { reason } : {}),
398
415
  ...(answers ? { answers } : {}),
399
416
  requestedBy: "dashboard",
417
+ requestedByUser: requestUserAddress(req),
400
418
  requestedAt: Date.now(),
401
419
  },
402
420
  });
@@ -410,7 +428,7 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
410
428
  icon: decision === "deny" || decision === "abort" ? "ti-circle-x" : "ti-circle-check",
411
429
  view: "chat",
412
430
  agentId: agent.id,
413
- metadata: { decision, approvalId, commandId: command.id, ...authAuditMetadata(req) },
431
+ metadata: { decision, approvalId, commandId: command.id, requestedByUser: requestUserAddress(req), ...authAuditMetadata(req) },
414
432
  });
415
433
  return json({ ok: true, decision, command }, 202);
416
434
  } catch (e) {
@@ -419,6 +437,39 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
419
437
  }
420
438
  };
421
439
 
440
+ // Dashboard viewer presence heartbeat (#390): "I am viewing/typing in this agent's chat".
441
+ // The client re-announces on an interval and on a `leaving` beacon. Keyed by the existing
442
+ // X-Dashboard-Client-Id so multiple tabs of one browser collapse to one viewer; the acting
443
+ // human comes from the TRUSTED requestUserAddress (NOT the client X-Dashboard-User-Id header),
444
+ // so a crafted header can't make presence show you as someone else (#390 forgery fix). We
445
+ // broadcast the live viewer set so every open dashboard updates without polling. No auth gate —
446
+ // these are non-secret opaque ids and the view-state of a chat already streams over the same SSE
447
+ // channel.
448
+ export const postAgentPresence: Handler = async (req, params) => {
449
+ const parsed = await parseBody<unknown>(req);
450
+ if (!parsed.ok) return error(parsed.error, parsed.status);
451
+ const agent = getAgent(params.id!);
452
+ if (!agent) return error("agent not found", 404);
453
+ const clientId = req.headers.get("x-dashboard-client-id")?.trim();
454
+ if (!clientId) return error("X-Dashboard-Client-Id required");
455
+ const body = isRecord(parsed.body) ? parsed.body : {};
456
+ const viewers = announcePresence({
457
+ agentId: agent.id,
458
+ clientId,
459
+ userId: requestUserAddress(req),
460
+ typing: body.typing === true,
461
+ leaving: body.leaving === true,
462
+ });
463
+ emitDashboardPresence(agent.id, viewers);
464
+ return json({ ok: true, agentId: agent.id, viewers });
465
+ };
466
+
467
+ export const getAgentPresence: Handler = (_req, params) => {
468
+ const agent = getAgent(params.id!);
469
+ if (!agent) return error("agent not found", 404);
470
+ return json({ agentId: agent.id, viewers: viewersForAgent(agent.id) });
471
+ };
472
+
422
473
  export const postAgentTerminalSession: Handler = async (req, params) => {
423
474
  try {
424
475
  const agent = getAgent(params.id!);
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents.
2
- import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, type Handler } from "./_shared";
2
+ import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
3
3
  import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
4
  import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
5
  import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
@@ -45,7 +45,11 @@ export const getAgents: Handler = (req) => {
45
45
  const tag = url.searchParams.get("tag") ?? undefined;
46
46
  const machine = url.searchParams.get("machine") ?? undefined;
47
47
  const status = url.searchParams.get("status") ?? undefined;
48
- return json(attachIssueRepos(attachBranchStates(listAgents({ tag, machine, status }))));
48
+ // #392: hide agents the acting user may not see. Fail-open in the single-user world
49
+ // (visibleAgentIdsForRequest returns every id), so today's list is byte-for-byte unchanged.
50
+ const agents = listAgents({ tag, machine, status });
51
+ const visible = visibleAgentIdsForRequest(req, agents.map((a) => a.id));
52
+ return json(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id)))));
49
53
  };
50
54
 
51
55
  export const findAgents: Handler = (req) => {
@@ -56,9 +60,12 @@ export const findAgents: Handler = (req) => {
56
60
  return json(searchAgents({ capability, status: includeAll ? "all" : "running" }));
57
61
  };
58
62
 
59
- export const getAgentById: Handler = (_req, params) => {
63
+ export const getAgentById: Handler = (req, params) => {
60
64
  const agent = getAgent(params.id!);
61
- return agent ? json(attachIssueRepo(attachBranchState(agent))) : error("agent not found", 404);
65
+ if (!agent) return error("agent not found", 404);
66
+ // #392: a non-visible agent must look indistinguishable from a missing one (no existence leak).
67
+ if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
68
+ return json(attachIssueRepo(attachBranchState(agent)));
62
69
  };
63
70
 
64
71
  export const getAgentReplyObligations: Handler = (_req, params) => {
@@ -69,6 +76,8 @@ export const getAgentReplyObligations: Handler = (_req, params) => {
69
76
  export const getAgentChatHistory: Handler = (req, params) => {
70
77
  const agent = getAgent(params.id!);
71
78
  if (!agent) return error("agent not found", 404);
79
+ // #392: chat history is only readable for agents the user may see (existence-hiding 404).
80
+ if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
72
81
  const url = new URL(req.url);
73
82
  const beforeRaw = parseQueryInt(url.searchParams.get("before"), { min: 1, max: Number.MAX_SAFE_INTEGER });
74
83
  if (Number.isNaN(beforeRaw)) return error("before must be a positive integer");
@@ -1,7 +1,8 @@
1
1
  // Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
2
2
  import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
3
3
  import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
4
- import { deleteAgentTerminalSession, postAgentAction, postAgentPermissionDecision, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
4
+ import { deleteAgentTerminalSession, getAgentPresence, postAgentAction, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
+ import { getUsers } from "./users";
5
6
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
6
7
  import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
7
8
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
@@ -85,6 +86,8 @@ const routes: Route[] = [
85
86
  route("PATCH", "/api/memories/:id", patchMemory),
86
87
  route("DELETE", "/api/memories/:id", deleteMemoryRoute),
87
88
 
89
+ route("GET", "/api/users", getUsers),
90
+
88
91
  route("POST", "/api/agents", postAgent),
89
92
  route("POST", "/api/agents/spawn", postAgentSpawn),
90
93
  route("GET", "/api/agents", getAgents),
@@ -111,6 +114,8 @@ const routes: Route[] = [
111
114
  route("POST", "/api/agents/:id/actions", postAgentAction),
112
115
  route("POST", "/api/agents/:id/prompt", postAgentPrompt),
113
116
  route("POST", "/api/agents/:id/permission-decision", postAgentPermissionDecision),
117
+ route("GET", "/api/agents/:id/presence", getAgentPresence),
118
+ route("POST", "/api/agents/:id/presence", postAgentPresence),
114
119
  route("POST", "/api/agents/:id/terminal-session", postAgentTerminalSession),
115
120
  route("DELETE", "/api/agents/:id/terminal-session/:session", deleteAgentTerminalSession),
116
121
  route("DELETE", "/api/agents/:id", deleteAgentById),
@@ -1,16 +1,23 @@
1
1
  // Auto-split from routes.ts (#299). Domain: schema-driven settings.
2
2
  import { ValidationError } from "../db";
3
3
  import { getConfig, setConfig } from "../config-store";
4
+ import { getUserSetting, setUserSetting } from "../user-settings-store";
4
5
  import { ensureProviderConfigDescriptors, migrateLocalProviderConfigs } from "../provider-config-store";
5
6
  import { getSettingDescriptor, listSettingDescriptors, validateSettingValue, PROVIDER_CONFIG_NAMESPACE } from "../settings/registry";
6
7
  import { isRequestAuthorizedFor } from "../security";
7
8
  import { cleanString } from "../validation";
8
9
  import { emitConfigChanged } from "../sse";
9
- import { error, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
10
- import { isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
10
+ import { error, json, normalizeConfigPathParam, parseBody, requestUserId, type Handler } from "./_shared";
11
+ import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
11
12
 
12
13
  type SettingEnvelope = SettingEntry;
13
14
 
15
+ // Which human a `tier:"user"` (#391) read/write resolves to: the ONE canonical
16
+ // `requestUserId` resolver (#388 Wave A integration), shared with authz (#392) and
17
+ // attribution (#390) so the three seams never diverge. Until sessions (#389) upgrade it,
18
+ // every request resolves to the seeded admin `user:default` (#393), so prefs read back exactly
19
+ // as before the migration (back-compat). Server-runtime/bootstrap/browser tiers ignore this.
20
+
14
21
  export const getSettingsRoute: Handler = (req) => {
15
22
  migrateLocalProviderConfigs();
16
23
  ensureProviderConfigDescriptors();
@@ -42,7 +49,9 @@ export const putSettingRoute: Handler = (req, params) => putSetting(req, params.
42
49
  export function getRegisteredSettingEntry(namespace: string, key: string): ConfigEntry | null {
43
50
  ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
44
51
  const descriptor = getSettingDescriptor(namespace, key);
45
- return descriptor ? currentSettingEntry(descriptor) : null;
52
+ // Programmatic (no request) reads have no session, so a user-tier descriptor resolves to the
53
+ // default human (#393) — the single human today; matches the HTTP path's back-compat default.
54
+ return descriptor ? currentSettingEntry(descriptor, DEFAULT_USER_ID) : null;
46
55
  }
47
56
 
48
57
  export function setRegisteredSettingValue(namespace: string, key: string, value: unknown, updatedBy?: string): ConfigEntry {
@@ -52,6 +61,7 @@ export function setRegisteredSettingValue(namespace: string, key: string, value:
52
61
  const merged = mergeSettingDefaults(descriptor.defaults, value);
53
62
  const validation = validateSettingValue(namespace, key, merged);
54
63
  if (!validation.valid) throw new ValidationError(validation.errors.join("; "));
64
+ if (descriptor.tier === "user") return setUserSetting(DEFAULT_USER_ID, namespace, key, merged, updatedBy);
55
65
  return setConfig(namespace, key, merged, updatedBy);
56
66
  }
57
67
 
@@ -68,11 +78,17 @@ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw
68
78
  ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
69
79
  const descriptor = getSettingDescriptor(namespace, key);
70
80
  if (!descriptor) return error("setting not found", 404);
71
- if (descriptor.tier !== "server-runtime") return error("setting is read-only", 403);
81
+ if (descriptor.tier !== "server-runtime" && descriptor.tier !== "user") return error("setting is read-only", 403);
72
82
  if (!canAccessSetting(req, descriptor.scope.write)) return error("forbidden", 403);
73
83
  const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
74
84
  const validation = validateSettingValue(namespace, key, parsed.body.value);
75
85
  if (!validation.valid) return json({ error: "invalid setting value", details: validation.errors }, 400);
86
+ if (descriptor.tier === "user") {
87
+ const userId = requestUserId(req);
88
+ const userEntry = setUserSetting(userId, namespace, key, parsed.body.value, updatedBy);
89
+ emitConfigChanged(userEntry.namespace, userEntry.key, userEntry.version);
90
+ return json(settingEnvelope(req, descriptor, userEntry), userEntry.version === 1 ? 201 : 200);
91
+ }
76
92
  const entry = setConfig(namespace, key, parsed.body.value, updatedBy);
77
93
  emitConfigChanged(entry.namespace, entry.key, entry.version);
78
94
  return json(settingEnvelope(req, descriptor, entry), entry.version === 1 ? 201 : 200);
@@ -88,7 +104,12 @@ function listVisibleSettings(req: Request): SettingEnvelope[] {
88
104
  .filter((setting) => setting.readable);
89
105
  }
90
106
 
91
- function settingEnvelope(req: Request, descriptor: SettingDescriptor, entry = currentSettingEntry(descriptor)): SettingEnvelope {
107
+ function settingEnvelope(
108
+ req: Request,
109
+ descriptor: SettingDescriptor,
110
+ entry = currentSettingEntry(descriptor, requestUserId(req)),
111
+ ): SettingEnvelope {
112
+ const mutableTier = descriptor.tier === "server-runtime" || descriptor.tier === "user";
92
113
  return {
93
114
  ...descriptor,
94
115
  value: entry.value,
@@ -96,12 +117,16 @@ function settingEnvelope(req: Request, descriptor: SettingDescriptor, entry = cu
96
117
  updatedAt: entry.updatedAt,
97
118
  updatedBy: entry.updatedBy,
98
119
  readable: canAccessSetting(req, descriptor.scope.read),
99
- writable: descriptor.tier === "server-runtime" && canAccessSetting(req, descriptor.scope.write),
120
+ writable: mutableTier && canAccessSetting(req, descriptor.scope.write),
100
121
  };
101
122
  }
102
123
 
103
- function currentSettingEntry(descriptor: SettingDescriptor): ConfigEntry {
104
- const entry = getConfig(descriptor.namespace, descriptor.key);
124
+ // Resolve the current stored value for a descriptor. `tier:"user"` (#391) reads the per-user row
125
+ // for `userId`; every other tier reads the instance-global `config` table. Unset → descriptor defaults.
126
+ function currentSettingEntry(descriptor: SettingDescriptor, userId: string): ConfigEntry {
127
+ const entry = descriptor.tier === "user"
128
+ ? getUserSetting(userId, descriptor.namespace, descriptor.key)
129
+ : getConfig(descriptor.namespace, descriptor.key);
105
130
  if (entry) return entry;
106
131
  return {
107
132
  namespace: descriptor.namespace,
@@ -1,9 +1,10 @@
1
1
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
2
2
  import { authContextFromRequest } from "../services/auth-context";
3
- import { getTeam, getTeamOutcomeAggregates, listAllTeams, ValidationError } from "../db";
4
- import { dissolveCallerTeam, dissolveTeamById, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError, type TeamDissolveServiceInput } from "../services/team";
3
+ import { getTeam, getTeamOutcomeAggregates, getPlanByTeam, listAllTeams, ValidationError } from "../db";
4
+ import { dissolveCallerTeam, dissolveTeamById, deriveTeamRuntimeState, getCallerTeam, setCallerTeamBrief, TeamAuthError, TeamNotFoundError, type TeamDissolveServiceInput } from "../services/team";
5
5
  import { cleanString, optionalEnum } from "../validation";
6
6
  import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
7
+ import { STALE_TTL_MS } from "../config";
7
8
  import type { Team } from "../types";
8
9
 
9
10
  const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
@@ -43,8 +44,14 @@ export const getTeamsRoute: Handler = (req) => {
43
44
  if (denied) return denied;
44
45
  try {
45
46
  const status = optionalEnum(new URL(req.url).searchParams.get("status") ?? undefined, "status", TEAM_STATUSES) as Team["status"] | undefined;
46
- const teams = listAllTeams();
47
- return json({ teams: status ? teams.filter((team) => team.status === status) : teams });
47
+ const cutoff = Date.now() - STALE_TTL_MS;
48
+ const all = listAllTeams();
49
+ const filtered = status ? all.filter((team) => team.status === status) : all;
50
+ const teams = filtered.map((team) => {
51
+ const plan = team.status === "active" ? getPlanByTeam(team.id) ?? null : null;
52
+ return { ...team, runtimeState: deriveTeamRuntimeState(team, plan, cutoff) };
53
+ });
54
+ return json({ teams });
48
55
  } catch (err) {
49
56
  return routeError(err);
50
57
  }
@@ -0,0 +1,8 @@
1
+ // Public user directory for dashboard attribution & presentation (#390). Surfaces the
2
+ // keystone `users` table (#393) so the dashboard can resolve a `user:<id>` message address
3
+ // to a human name/avatar. Read-only; the user row holds no secrets (auth credentials land
4
+ // separately in #389), so the whole row is dashboard-safe to return.
5
+ import { listUsers } from "../db";
6
+ import { json, type Handler } from "./_shared";
7
+
8
+ export const getUsers: Handler = () => json(listUsers());
@@ -9,7 +9,8 @@
9
9
  // managed came-up-running reconcile (delegated to managed-running.ts), the single
10
10
  // status broadcast, the single timeline note, the spawned-child parent-wake, and
11
11
  // the first-registration audit row.
12
- import { createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
12
+ import { isReservedAgentId } from "agent-relay-sdk";
13
+ import { ValidationError, createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
13
14
  import { emitAgentStatus, emitNewMessage } from "../sse";
14
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
15
16
  import { notifyAgentReady } from "../agent-lifecycle-events";
@@ -24,6 +25,15 @@ function metaStr(meta: Record<string, unknown> | undefined, key: string): string
24
25
  }
25
26
 
26
27
  export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): AgentCard {
28
+ // Reserved built-in identities — the `system` lifecycle sender and the whole `user:` human
29
+ // namespace (legacy bare `'user'`, `user:default`, every `user:<id>`) — are mechanical sinks,
30
+ // never registrable agents. THIS is the one external-registration boundary (HTTP `postAgent`
31
+ // and bus `handleRegister` both land here); rejecting them closes the spoof where a provider
32
+ // token squats a human address to receive human-addressed mail (#393). Internal built-in
33
+ // seeding writes straight through `upsertAgent`, bypassing this gate, so it stays unaffected.
34
+ if (isReservedAgentId(input.id)) {
35
+ throw new ValidationError(`agent id "${input.id}" is reserved and cannot be registered`);
36
+ }
27
37
  // Lineage is authoritative from the registering token's signed constraints — never
28
38
  // the client-sent body (a child can't forge its own parent). Set by relay at spawn.
29
39
  const lineage = resolveSpawnLineage(ctx.constraints);
@@ -21,7 +21,7 @@
21
21
  //
22
22
  // OUT OF SCOPE (transport-edge, excluded from the parity facet): automatic memory injection
23
23
  // (needs request-derived context) and per-transport audit (HTTP domain row vs MCP tool-call row).
24
- import { isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
24
+ import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
25
25
  import {
26
26
  ValidationError,
27
27
  getMessage,
@@ -159,6 +159,12 @@ export function sendMessageService(
159
159
  ): SendMessageResult {
160
160
  applyReplyRouting(input);
161
161
  if (!input.to) throw new ValidationError("to is required (or provide replyTo to auto-route)");
162
+ // De-singletoned human addressing (#393): collapse a human address to its stored/canonical
163
+ // form so `user:default` is a fully WORKING alias of the legacy bare `'user'` sink — it routes
164
+ // to the same agent row + inbox + thread, with zero live-byte movement. Per-user `user:<id>`
165
+ // addresses pass through unchanged for the later #388 waves; non-human ids are untouched.
166
+ if (isHumanAgentId(input.to)) input.to = canonicalHumanAgentId(input.to);
167
+ if (input.from && isHumanAgentId(input.from)) input.from = canonicalHumanAgentId(input.from);
162
168
  // The reserved-sink observability lane (#284) governs both `from` resolution (wire `from` is
163
169
  // authoritative — the runner reports an agent's turn) and authorization (drop the recipient
164
170
  // predicate). Compute once, after reply-routing has settled `to`.