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,169 @@
1
+ // Agent authorization (#388 users wave 3, #392). THE single home for "which human may see and
2
+ // drive which agent". Two dimensions compose here:
3
+ //
4
+ // 1. ROLE → action — admin/operator/viewer map onto the EXISTING scope sets (no parallel
5
+ // authz): an action is allowed iff the role's scopes satisfy the scope that gates it, via
6
+ // the same `hasScope` used by `requiredComponentScopeFor`. viewer observes, operator
7
+ // drives, admin manages.
8
+ // 2. VISIBILITY + ownership — private (owner/admin only), shared (all users), team (team
9
+ // members/admin) decide whether the agent is in view at all.
10
+ //
11
+ // Fail-closed: an unknown role, or a viewer that satisfies neither ownership nor visibility, is
12
+ // denied. Fail-open back-compat: while only the seeded single user exists, every gate passes so
13
+ // the legacy single-operator flow is byte-for-byte unchanged (the gate only bites once a second
14
+ // user is added — `multiUser`).
15
+ import { hasScope, type UserRole } from "agent-relay-sdk";
16
+ import { isMultiUserWorld, resolveAgentOwnership, type AgentOwnership, type AgentVisibility } from "./db/agent-ownership.ts";
17
+ import { getUser } from "./db/users.ts";
18
+
19
+ /** What a human is trying to do to an agent. Maps 1:1 onto a gating scope (see ACTION_SCOPE). */
20
+ export type AgentAction = "view" | "drive" | "manage";
21
+
22
+ /** The resolved human behind a request: their relational id, role, and (optional) team set. */
23
+ export interface AgentViewer {
24
+ userId: string;
25
+ role: UserRole;
26
+ /** Teams the user belongs to — consulted only for `team`-visibility agents. */
27
+ teamIds?: string[];
28
+ }
29
+
30
+ /**
31
+ * Scope sets per role, expressed in the SAME vocabulary as `requiredComponentScopeFor`, so the
32
+ * user-authz gate reuses the existing scope model instead of inventing a parallel one. `admin`
33
+ * holds `system:admin` (full reach via `hasScope`); operator can drive; viewer is read-only.
34
+ * Unknown roles map to the empty set → every `hasScope` check is false → fail-closed.
35
+ */
36
+ const ROLE_SCOPES: Record<UserRole, readonly string[]> = {
37
+ admin: ["system:admin"],
38
+ operator: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "command:spawn", "task:read", "task:write", "teams:read", "stats:read"],
39
+ viewer: ["agent:read", "message:read", "command:read", "task:read", "teams:read", "stats:read"],
40
+ };
41
+
42
+ /** The scope each agent action is gated by — drive/manage reuse the same scopes the routes do. */
43
+ const ACTION_SCOPE: Record<AgentAction, string> = {
44
+ view: "agent:read",
45
+ drive: "agent:write",
46
+ manage: "system:admin",
47
+ };
48
+
49
+ /** The scope set a role carries. Unknown role → empty set (fail-closed). */
50
+ export function scopesForRole(role: UserRole | string | undefined): readonly string[] {
51
+ return ROLE_SCOPES[role as UserRole] ?? [];
52
+ }
53
+
54
+ /** Does this role's scope set satisfy `scope`? Reuses the SDK `hasScope` (handles `*`/`:*`). */
55
+ export function roleHasScope(role: UserRole | string | undefined, scope: string): boolean {
56
+ return hasScope(scopesForRole(role), scope);
57
+ }
58
+
59
+ /** Can this role perform `action` at all (role dimension only — ignores ownership)? */
60
+ export function roleAllowsAction(role: UserRole | string | undefined, action: AgentAction): boolean {
61
+ return roleHasScope(role, ACTION_SCOPE[action]);
62
+ }
63
+
64
+ export interface AuthorizeOptions {
65
+ /** True once more than one user exists — the gate only enforces in a multi-user world.
66
+ * Single-user (or zero) → every check passes (legacy fail-open back-compat). */
67
+ multiUser: boolean;
68
+ }
69
+
70
+ /**
71
+ * Can `viewer` see an agent with this `ownership`? Visibility/ownership dimension only.
72
+ * - single-user world → always (fail-open).
73
+ * - admin → always (admin sees everything it manages).
74
+ * - private → owner only.
75
+ * - shared → any user.
76
+ * - team → the owning team's members (and admin, handled above).
77
+ * Unknown/garbage visibility falls through to deny (fail-closed).
78
+ */
79
+ function viewerCanSeeOwnership(viewer: AgentViewer, ownership: AgentOwnership, opts: AuthorizeOptions): boolean {
80
+ if (!opts.multiUser) return true;
81
+ if (viewer.role === "admin") return true;
82
+ if (viewer.userId === ownership.ownerUserId) return true;
83
+ const visibility: AgentVisibility = ownership.visibility;
84
+ if (visibility === "shared") return true;
85
+ if (visibility === "team") return Boolean(ownership.teamId && viewer.teamIds?.includes(ownership.teamId));
86
+ // "private" and any unrecognized value → deny.
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * THE authorization decision: may `viewer` perform `action` on the agent owned per `ownership`?
92
+ * Both dimensions must pass — the role must permit the action AND the agent must be visible to
93
+ * the viewer. Fail-open while single-user; fail-closed otherwise.
94
+ */
95
+ export function authorizeAgentAction(
96
+ viewer: AgentViewer,
97
+ ownership: AgentOwnership,
98
+ action: AgentAction,
99
+ opts: AuthorizeOptions,
100
+ ): boolean {
101
+ if (!opts.multiUser) return true;
102
+ if (!roleAllowsAction(viewer.role, action)) return false;
103
+ return viewerCanSeeOwnership(viewer, ownership, opts);
104
+ }
105
+
106
+ /** Convenience: can the viewer observe (list/chat-read) this agent? */
107
+ export function canViewerSeeAgent(viewer: AgentViewer, ownership: AgentOwnership, opts: AuthorizeOptions): boolean {
108
+ return authorizeAgentAction(viewer, ownership, "view", opts);
109
+ }
110
+
111
+ /** Convenience: can the viewer drive (prompt/interrupt/approve) this agent? */
112
+ export function canViewerDriveAgent(viewer: AgentViewer, ownership: AgentOwnership, opts: AuthorizeOptions): boolean {
113
+ return authorizeAgentAction(viewer, ownership, "drive", opts);
114
+ }
115
+
116
+ /**
117
+ * Filter a list of (agent, ownership) pairs down to what `viewer` may observe. The list-gating
118
+ * primitive behind "a private agent is invisible to other users".
119
+ */
120
+ export function filterVisibleAgents<T>(
121
+ items: ReadonlyArray<{ item: T; ownership: AgentOwnership }>,
122
+ viewer: AgentViewer,
123
+ opts: AuthorizeOptions,
124
+ ): T[] {
125
+ return items.filter(({ ownership }) => canViewerSeeAgent(viewer, ownership, opts)).map(({ item }) => item);
126
+ }
127
+
128
+ // ── DB-wired entry points ────────────────────────────────────────────────────────────────────
129
+ // The above is pure (no DB) so the role/visibility matrix is testable in isolation. These tie it
130
+ // to persistence — resolving the viewer's role from `users`, the agent's ownership row, and the
131
+ // live multi-user switch — and are what the transport/route layer (the request→user wiring lands
132
+ // with the sessions sub-issue) calls.
133
+
134
+ /** Resolve the live authz options (the single-vs-multi-user switch) from the DB. */
135
+ export function currentAuthorizeOptions(): AuthorizeOptions {
136
+ return { multiUser: isMultiUserWorld() };
137
+ }
138
+
139
+ /** Build a viewer from a user id by reading their role off the `users` row. Undefined for an
140
+ * unknown user (the caller must then fail closed — an unresolvable principal drives nothing). */
141
+ export function resolveViewer(userId: string | undefined, teamIds?: string[]): AgentViewer | undefined {
142
+ if (!userId) return undefined;
143
+ const user = getUser(userId);
144
+ if (!user) return undefined;
145
+ return { userId: user.id, role: user.role, teamIds };
146
+ }
147
+
148
+ /**
149
+ * Wired decision: may the user behind `userId` perform `action` on `agentId`? Resolves the
150
+ * viewer, the agent's ownership, and the multi-user switch from the DB, then defers to the pure
151
+ * `authorizeAgentAction`. An unknown user is denied UNLESS the relay is still single-user (legacy
152
+ * fail-open), matching every other gate here.
153
+ */
154
+ export function authorizeAgentForUser(userId: string | undefined, agentId: string, action: AgentAction): boolean {
155
+ const opts = currentAuthorizeOptions();
156
+ if (!opts.multiUser) return true;
157
+ const viewer = resolveViewer(userId);
158
+ if (!viewer) return false;
159
+ return authorizeAgentAction(viewer, resolveAgentOwnership(agentId), action, opts);
160
+ }
161
+
162
+ /** Wired list-gate: the subset of `agentIds` the user may observe (drives agent-list visibility). */
163
+ export function visibleAgentIdsForUser(userId: string | undefined, agentIds: readonly string[]): string[] {
164
+ const opts = currentAuthorizeOptions();
165
+ if (!opts.multiUser) return [...agentIds];
166
+ const viewer = resolveViewer(userId);
167
+ if (!viewer) return [];
168
+ return agentIds.filter((agentId) => canViewerSeeAgent(viewer, resolveAgentOwnership(agentId), opts));
169
+ }
package/src/agent-ref.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { STALE_TTL_MS } from "./config";
15
15
  import type { AgentCard, Message, TokenConstraints } from "./types";
16
+ import { isReservedAgentId } from "agent-relay-sdk";
16
17
  import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
17
18
 
18
19
  interface ResolveOptions {
@@ -191,10 +192,9 @@ type SendPlan =
191
192
 
192
193
  const FANOUT_PREFIXES = ["tag:", "cap:", "capability:", "label:", "team:"];
193
194
  const PASSTHROUGH_PREFIXES = ["policy:", "orchestrator:", "pool:"];
194
- const RESERVED_TARGETS = new Set(["user", "system"]);
195
195
 
196
196
  function isPseudoAgent(agent: AgentCard): boolean {
197
- return agent.id !== "user" && agent.id !== "system" && agent.kind !== "channel" && (agent.meta?.kind as unknown) !== "channel";
197
+ return !isReservedAgentId(agent.id) && agent.kind !== "channel" && (agent.meta?.kind as unknown) !== "channel";
198
198
  }
199
199
 
200
200
  function describeAgent(agent: AgentCard): string {
@@ -236,7 +236,11 @@ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions =
236
236
  .filter((a) => isAgentOnline(a, now)).map((a) => a.id);
237
237
  return { kind: "fanout", to: target, receipt: fanoutReceipt(recipients) };
238
238
  }
239
- if (RESERVED_TARGETS.has(target) || PASSTHROUGH_PREFIXES.some((p) => target.startsWith(p))) {
239
+ // Reserved-human (`user`, the whole `user:` namespace) + `system` pass straight through to the
240
+ // canonical sink — they must NEVER direct-resolve to a registered agent, or a provider squatting
241
+ // a human address would intercept human-addressed mail (#393). Same predicate the registration
242
+ // gate and the SDK use, so the namespace can't drift between "what's reserved" and "what routes".
243
+ if (isReservedAgentId(target) || PASSTHROUGH_PREFIXES.some((p) => target.startsWith(p))) {
240
244
  return { kind: "passthrough", to: target, receipt: { delivered: true, expectReply: true, recipients: [target] } };
241
245
  }
242
246
 
@@ -0,0 +1,91 @@
1
+ // Lightweight dashboard presence for #390: "who is viewing / typing in this agent's chat".
2
+ // Ephemeral, in-memory, TTL-pruned — never persisted. A dashboard announces its presence on
3
+ // an agent thread via a heartbeat; we key each viewer by agentId + clientId so the same
4
+ // browser counts once across tabs, and expire it when the heartbeat stops. This is the
5
+ // human-side complement to the agent-liveness presence in dashboard/lib/display.ts.
6
+ import { canonicalHumanAgentId, isHumanAgentId, LEGACY_HUMAN_AGENT_ID } from "agent-relay-sdk";
7
+
8
+ /** A heartbeat older than this is considered gone (the client re-announces well within it). */
9
+ export const PRESENCE_TTL_MS = 20_000;
10
+
11
+ export interface PresenceViewer {
12
+ /** Canonical human address (`user` / `user:<id>`) of the viewer. */
13
+ userId: string;
14
+ /** Stable per-browser id, so multiple tabs of one browser collapse to one viewer. */
15
+ clientId: string;
16
+ /** Whether the viewer's composer currently has draft text aimed at this agent. */
17
+ typing: boolean;
18
+ /** Epoch ms of the latest heartbeat. */
19
+ lastSeen: number;
20
+ }
21
+
22
+ interface PresenceRecord extends PresenceViewer {
23
+ agentId: string;
24
+ }
25
+
26
+ // Keyed by agentId + clientId so one browser viewing one agent is a single row.
27
+ const presence = new Map<string, PresenceRecord>();
28
+
29
+ function key(agentId: string, clientId: string): string {
30
+ return `${agentId}\t${clientId}`;
31
+ }
32
+
33
+ function prune(now: number): void {
34
+ for (const [k, rec] of presence) {
35
+ if (now - rec.lastSeen > PRESENCE_TTL_MS) presence.delete(k);
36
+ }
37
+ }
38
+
39
+ /** Normalize a raw header userId to a canonical human address, defaulting to the legacy sink. */
40
+ function normalizeUserId(raw: string | undefined): string {
41
+ if (raw && isHumanAgentId(raw)) return canonicalHumanAgentId(raw);
42
+ return LEGACY_HUMAN_AGENT_ID;
43
+ }
44
+
45
+ interface AnnounceInput {
46
+ agentId: string;
47
+ clientId: string;
48
+ userId?: string;
49
+ typing?: boolean;
50
+ /** Explicit "I navigated away" — removes the viewer without waiting for TTL. */
51
+ leaving?: boolean;
52
+ now?: number;
53
+ }
54
+
55
+ /**
56
+ * Record (or clear) a dashboard's presence on an agent thread and return the current live
57
+ * viewers for that agent. Pure given `now`, so it is deterministic to test.
58
+ */
59
+ export function announcePresence(input: AnnounceInput): PresenceViewer[] {
60
+ const now = input.now ?? Date.now();
61
+ prune(now);
62
+ const k = key(input.agentId, input.clientId);
63
+ if (input.leaving) {
64
+ presence.delete(k);
65
+ } else {
66
+ presence.set(k, {
67
+ agentId: input.agentId,
68
+ clientId: input.clientId,
69
+ userId: normalizeUserId(input.userId),
70
+ typing: input.typing === true,
71
+ lastSeen: now,
72
+ });
73
+ }
74
+ return viewersForAgent(input.agentId, now);
75
+ }
76
+
77
+ /** Live viewers for an agent, newest heartbeat first. Prunes stale records as a side effect. */
78
+ export function viewersForAgent(agentId: string, now = Date.now()): PresenceViewer[] {
79
+ prune(now);
80
+ const out: PresenceViewer[] = [];
81
+ for (const rec of presence.values()) {
82
+ if (rec.agentId !== agentId) continue;
83
+ out.push({ userId: rec.userId, clientId: rec.clientId, typing: rec.typing, lastSeen: rec.lastSeen });
84
+ }
85
+ return out.sort((a, b) => b.lastSeen - a.lastSeen);
86
+ }
87
+
88
+ /** Test-only reset so suites don't leak presence across cases. */
89
+ export function _clearPresence(): void {
90
+ presence.clear();
91
+ }
@@ -0,0 +1,168 @@
1
+ // Agent ownership & visibility (#388 users wave 3, #392). The per-agent half of the
2
+ // user-authorization model: who created an agent (`owner_user_id` → users.id) and who else
3
+ // may see it (`visibility`). This module owns persistence ONLY — the role/visibility decision
4
+ // logic lives in `src/agent-authz.ts` (the single authz home that reuses the scope path).
5
+ //
6
+ // Back-compat invariant (#392): pre-existing agents are seeded as owned by the admin
7
+ // `user:default` with `visibility='shared'` on migration, so NOTHING becomes invisible when a
8
+ // second user is added. An agent with no row resolves to the same default (see
9
+ // `resolveAgentOwnership`) — unowned is treated as the admin's shared agent, never an orphan.
10
+ import { DEFAULT_USER_ID } from "agent-relay-sdk";
11
+ import { ValidationError, getDb } from "./connection.ts";
12
+
13
+ /**
14
+ * Who may see an agent:
15
+ * - `private` — owner (and any admin) only.
16
+ * - `shared` — every user, gated by their role (viewer observes, operator drives).
17
+ * - `team` — members of the owning `team_id` (and any admin).
18
+ */
19
+ export type AgentVisibility = "private" | "shared" | "team";
20
+
21
+ export const AGENT_VISIBILITIES: readonly AgentVisibility[] = ["private", "shared", "team"];
22
+
23
+ export interface AgentOwnership {
24
+ agentId: string;
25
+ ownerUserId: string;
26
+ visibility: AgentVisibility;
27
+ teamId: string | null;
28
+ createdAt: number;
29
+ updatedAt: number;
30
+ }
31
+
32
+ export interface SetAgentOwnershipInput {
33
+ agentId: string;
34
+ ownerUserId: string;
35
+ visibility?: AgentVisibility;
36
+ teamId?: string | null;
37
+ }
38
+
39
+ interface AgentOwnershipRow {
40
+ agent_id: string;
41
+ owner_user_id: string;
42
+ visibility: string;
43
+ team_id: string | null;
44
+ created_at: number;
45
+ updated_at: number;
46
+ }
47
+
48
+ /**
49
+ * Single DDL home for the `agent_ownership` table (#392). Called from applyMigrations so both
50
+ * fresh and upgraded DBs gain it (CREATE TABLE IF NOT EXISTS), mirroring `ensureUsersSchema`.
51
+ * The owner FK points at the keystone `users` table; ON DELETE CASCADE drops the row when the
52
+ * agent is removed so ownership never outlives its agent.
53
+ */
54
+ export function ensureAgentOwnershipSchema(): void {
55
+ getDb().run(`
56
+ CREATE TABLE IF NOT EXISTS agent_ownership (
57
+ agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
58
+ owner_user_id TEXT NOT NULL REFERENCES users(id),
59
+ visibility TEXT NOT NULL DEFAULT 'shared',
60
+ team_id TEXT,
61
+ created_at INTEGER NOT NULL,
62
+ updated_at INTEGER NOT NULL
63
+ )
64
+ `);
65
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_agent_ownership_owner ON agent_ownership(owner_user_id)");
66
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_agent_ownership_team ON agent_ownership(team_id)");
67
+ }
68
+
69
+ function normalizeVisibility(value: string): AgentVisibility {
70
+ return (AGENT_VISIBILITIES as readonly string[]).includes(value) ? (value as AgentVisibility) : "shared";
71
+ }
72
+
73
+ function rowToOwnership(row: AgentOwnershipRow): AgentOwnership {
74
+ return {
75
+ agentId: row.agent_id,
76
+ ownerUserId: row.owner_user_id,
77
+ visibility: normalizeVisibility(row.visibility),
78
+ teamId: row.team_id,
79
+ createdAt: row.created_at,
80
+ updatedAt: row.updated_at,
81
+ };
82
+ }
83
+
84
+ export function getAgentOwnership(agentId: string): AgentOwnership | undefined {
85
+ const row = getDb().query("SELECT * FROM agent_ownership WHERE agent_id = ?").get(agentId) as AgentOwnershipRow | null;
86
+ return row ? rowToOwnership(row) : undefined;
87
+ }
88
+
89
+ /**
90
+ * The ownership of an agent for authorization. A stored row wins; an unowned agent falls back to
91
+ * the back-compat default — owned by the admin `user:default`, `shared` — so a not-yet-attributed
92
+ * agent stays visible/drivable exactly as it was pre-#392 instead of vanishing (fail-open for the
93
+ * legacy world; the multi-user gate in `agent-authz.ts` is what tightens this once it matters).
94
+ */
95
+ export function resolveAgentOwnership(agentId: string): AgentOwnership {
96
+ const stored = getAgentOwnership(agentId);
97
+ if (stored) return stored;
98
+ const now = Date.now();
99
+ return { agentId, ownerUserId: DEFAULT_USER_ID, visibility: "shared", teamId: null, createdAt: now, updatedAt: now };
100
+ }
101
+
102
+ export function listAgentOwnership(): AgentOwnership[] {
103
+ const rows = getDb().query("SELECT * FROM agent_ownership ORDER BY created_at").all() as AgentOwnershipRow[];
104
+ return rows.map(rowToOwnership);
105
+ }
106
+
107
+ /** Upsert an agent's owner/visibility/team. Idempotent on `agent_id`; preserves `created_at`. */
108
+ export function setAgentOwnership(input: SetAgentOwnershipInput): AgentOwnership {
109
+ if (!input.agentId) throw new ValidationError("agentId is required");
110
+ if (!input.ownerUserId) throw new ValidationError("ownerUserId is required");
111
+ const visibility = input.visibility ?? "shared";
112
+ if (!(AGENT_VISIBILITIES as readonly string[]).includes(visibility)) {
113
+ throw new ValidationError(`invalid visibility: ${visibility}`);
114
+ }
115
+ const now = Date.now();
116
+ getDb()
117
+ .query(
118
+ `INSERT INTO agent_ownership (agent_id, owner_user_id, visibility, team_id, created_at, updated_at)
119
+ VALUES (?, ?, ?, ?, ?, ?)
120
+ ON CONFLICT(agent_id) DO UPDATE SET
121
+ owner_user_id = excluded.owner_user_id,
122
+ visibility = excluded.visibility,
123
+ team_id = excluded.team_id,
124
+ updated_at = excluded.updated_at`,
125
+ )
126
+ .run(input.agentId, input.ownerUserId, visibility, input.teamId ?? null, now, now);
127
+ const saved = getAgentOwnership(input.agentId);
128
+ if (!saved) throw new ValidationError(`failed to set ownership for agent ${input.agentId}`);
129
+ return saved;
130
+ }
131
+
132
+ export function deleteAgentOwnership(agentId: string): void {
133
+ getDb().query("DELETE FROM agent_ownership WHERE agent_id = ?").run(agentId);
134
+ }
135
+
136
+ /**
137
+ * True once more than one user exists — the switch that turns the authz gate ON. While only the
138
+ * seeded `user:default` exists, the relay is in its legacy single-operator world and every gate
139
+ * fail-opens (`agent-authz.ts`). Counted live so adding/removing a user flips enforcement with no
140
+ * extra state to keep in sync.
141
+ */
142
+ export function isMultiUserWorld(): boolean {
143
+ const row = getDb().query("SELECT count(*) AS n FROM users").get() as { n: number } | null;
144
+ return (row?.n ?? 0) > 1;
145
+ }
146
+
147
+ /**
148
+ * One-shot migration backfill (#392 back-compat): every existing real agent that lacks an
149
+ * ownership row is assigned to the admin `user:default`, `visibility='shared'`. Idempotent
150
+ * (INSERT … WHERE NOT EXISTS), so it runs safely on every boot. Built-in sinks (`user`,
151
+ * `system`, and any `user:<id>` human address) are skipped — they are not driveable workers and
152
+ * must not be owned. This is what guarantees "nothing becomes invisible on upgrade".
153
+ */
154
+ export function seedDefaultAgentOwnership(): void {
155
+ const now = Date.now();
156
+ getDb()
157
+ .query(
158
+ `INSERT INTO agent_ownership (agent_id, owner_user_id, visibility, team_id, created_at, updated_at)
159
+ SELECT a.id, ?, 'shared', NULL, ?, ?
160
+ FROM agents a
161
+ WHERE a.id NOT IN (SELECT agent_id FROM agent_ownership)
162
+ AND a.id != 'user'
163
+ AND a.id != 'system'
164
+ AND a.id NOT LIKE 'user:%'
165
+ AND coalesce(json_extract(a.meta, '$.builtin'), 0) != 1`,
166
+ )
167
+ .run(DEFAULT_USER_ID, now, now);
168
+ }
package/src/db/index.ts CHANGED
@@ -16,6 +16,8 @@ export * from "./messages.ts";
16
16
  export * from "./delivery.ts";
17
17
  export * from "./message-reads.ts";
18
18
  export * from "./inbox.ts";
19
+ export * from "./users.ts";
20
+ export * from "./agent-ownership.ts";
19
21
  export * from "./activity.ts";
20
22
  export * from "./stats.ts";
21
23
  export * from "./orchestrators.ts";
@@ -15,6 +15,8 @@ import {
15
15
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { getDb } from "./connection.ts";
18
+ import { ensureUsersSchema, seedDefaultUser } from "./users.ts";
19
+ import { ensureAgentOwnershipSchema, seedDefaultAgentOwnership } from "./agent-ownership.ts";
18
20
  import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
19
21
  import { normalizeReactionEmoji } from "./mappers.ts";
20
22
  import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
@@ -791,6 +793,17 @@ export function applyMigrations(): void {
791
793
  )
792
794
  `);
793
795
 
796
+ // First-class human users (#388 keystone #393): ensure the table (single DDL home in users.ts)
797
+ // and seed the admin-level default user mapped to the existing single human. P0 + idempotent —
798
+ // bare 'user' == user:default resolve to users.id='default' via the SDK resolver; no bytes move.
799
+ ensureUsersSchema();
800
+ seedDefaultUser();
801
+
802
+ // Agent ownership/visibility table (#392). FK-references both agents and users, so it ensures
803
+ // AFTER both exist. The backfill (seedDefaultAgentOwnership) runs below, once the built-in
804
+ // agents are in, so they can be excluded from ownership.
805
+ ensureAgentOwnershipSchema();
806
+
794
807
  // Built-in agents — registered unconditionally so sends from these ids
795
808
  // pass the sendMessage validation. The reaper exempts these by checking
796
809
  // meta.builtin (or by id for "user").
@@ -803,6 +816,10 @@ export function applyMigrations(): void {
803
816
  builtinStmt.run("user", "User", "user", '["human"]', now, now);
804
817
  builtinStmt.run("system", "System", "system", '["system"]', now, now);
805
818
 
819
+ // Back-compat backfill (#392): attribute every pre-existing real agent to the admin
820
+ // user:default as `shared`, so adding a second user never makes today's agents vanish.
821
+ seedDefaultAgentOwnership();
822
+
806
823
  // One-shot migration: backfill message_reads from legacy read_by JSON
807
824
  // if that column still carries data. Safe to run repeatedly (INSERT OR IGNORE).
808
825
  if (colNames.includes("read_by")) {
package/src/db/schema.ts CHANGED
@@ -462,6 +462,22 @@ export function initDb(path: string = "agent-relay.db"): Database {
462
462
  );
463
463
  CREATE INDEX IF NOT EXISTS idx_config_history_key ON config_history(namespace, key, version);
464
464
 
465
+ -- Per-user settings (#391, epic #388). The user dimension the global \`config\` table lacks:
466
+ -- the same \`(namespace, key)\` descriptor address resolves to a per-user value, keyed by the
467
+ -- resolved \`users.id\` (#393). \`config\` semantics are untouched — \`tier:"user"\` descriptors
468
+ -- route here; everything else stays in \`config\`. ON DELETE CASCADE so removing a user drops
469
+ -- their prefs with them.
470
+ CREATE TABLE IF NOT EXISTS user_settings (
471
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
472
+ namespace TEXT NOT NULL,
473
+ key TEXT NOT NULL,
474
+ value TEXT NOT NULL,
475
+ version INTEGER NOT NULL DEFAULT 1,
476
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
477
+ updated_by TEXT,
478
+ PRIMARY KEY (user_id, namespace, key)
479
+ );
480
+
465
481
  CREATE TABLE IF NOT EXISTS managed_agent_state (
466
482
  policy_name TEXT PRIMARY KEY,
467
483
  status TEXT NOT NULL,
package/src/db/teams.ts CHANGED
@@ -131,11 +131,20 @@ export function getTeamRoster(teamId: string): AgentCard[] {
131
131
  return rows.map(rowToAgent);
132
132
  }
133
133
 
134
- export function countLiveTeamMembers(teamId: string, cutoff: number): number {
134
+ export interface TeamMemberStats {
135
+ total: number;
136
+ live: number;
137
+ }
138
+
139
+ export function getTeamMemberStats(teamId: string, cutoff: number): TeamMemberStats {
135
140
  const row = getDb().query(
136
- "SELECT count(*) AS n FROM agents WHERE team_id = ? AND status NOT IN ('offline') AND last_seen > ?",
137
- ).get(teamId, cutoff) as { n: number };
138
- return row.n;
141
+ "SELECT count(*) AS total, count(CASE WHEN status != 'offline' AND last_seen > ? THEN 1 END) AS live FROM agents WHERE team_id = ?",
142
+ ).get(cutoff, teamId) as { total: number; live: number };
143
+ return { total: row.total, live: row.live };
144
+ }
145
+
146
+ export function countLiveTeamMembers(teamId: string, cutoff: number): number {
147
+ return getTeamMemberStats(teamId, cutoff).live;
139
148
  }
140
149
 
141
150
  export function getTeamBusyState(teamId: string, cutoff: number): TeamBusyState {