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.
- package/docs/openapi.json +103 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-ckJ2VVgq.js +3 -0
- package/public/assets/MessageBubble-ckJ2VVgq.js.map +1 -0
- package/public/assets/{PlanGraphPanel-o3u36Del.js → PlanGraphPanel-r3aVT4uc.js} +2 -2
- package/public/assets/{PlanGraphPanel-o3u36Del.js.map → PlanGraphPanel-r3aVT4uc.js.map} +1 -1
- package/public/assets/{activity-L3POfhGa.js → activity-DtMjjws8.js} +2 -2
- package/public/assets/{activity-L3POfhGa.js.map → activity-DtMjjws8.js.map} +1 -1
- package/public/assets/{agent-profiles-CeoqyRis.js → agent-profiles-eRlq98tp.js} +2 -2
- package/public/assets/{agent-profiles-CeoqyRis.js.map → agent-profiles-eRlq98tp.js.map} +1 -1
- package/public/assets/agents-DmbjRTA7.js +2 -0
- package/public/assets/{agents-DAdDtQb6.js.map → agents-DmbjRTA7.js.map} +1 -1
- package/public/assets/{analytics-B6wGSTf3.js → analytics-BW7ik_Ws.js} +2 -2
- package/public/assets/{analytics-B6wGSTf3.js.map → analytics-BW7ik_Ws.js.map} +1 -1
- package/public/assets/{automation-kakk3xZ9.js → automation-CxwGXWC7.js} +2 -2
- package/public/assets/{automation-kakk3xZ9.js.map → automation-CxwGXWC7.js.map} +1 -1
- package/public/assets/{badge-DGB2nVM0.js → badge-BwaoBnNO.js} +2 -2
- package/public/assets/{badge-DGB2nVM0.js.map → badge-BwaoBnNO.js.map} +1 -1
- package/public/assets/{branch-state-badge-BSwkl9A8.js → branch-state-badge-CvdKPTlV.js} +2 -2
- package/public/assets/{branch-state-badge-BSwkl9A8.js.map → branch-state-badge-CvdKPTlV.js.map} +1 -1
- package/public/assets/{button-D_Y8CEP4.js → button-CFIklTDX.js} +2 -2
- package/public/assets/{button-D_Y8CEP4.js.map → button-CFIklTDX.js.map} +1 -1
- package/public/assets/{card-DTLNgk0R.js → card-Bi94xEmr.js} +2 -2
- package/public/assets/{card-DTLNgk0R.js.map → card-Bi94xEmr.js.map} +1 -1
- package/public/assets/{channels-BzY-_4Zi.js → channels-D8UY3baa.js} +2 -2
- package/public/assets/{channels-BzY-_4Zi.js.map → channels-D8UY3baa.js.map} +1 -1
- package/public/assets/chat-CCPukbI9.js +2 -0
- package/public/assets/chat-CCPukbI9.js.map +1 -0
- package/public/assets/{connectors-empwp8UF.js → connectors-W2MslhQ2.js} +2 -2
- package/public/assets/{connectors-empwp8UF.js.map → connectors-W2MslhQ2.js.map} +1 -1
- package/public/assets/{copy-button-BpgTft5S.js → copy-button-A0pXLXBy.js} +2 -2
- package/public/assets/{copy-button-BpgTft5S.js.map → copy-button-A0pXLXBy.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js +3 -0
- package/public/assets/display-CWMHll1J.js.map +1 -0
- package/public/assets/{dist-DetIq2Rn.js → dist--NYpzm4f.js} +2 -2
- package/public/assets/{dist-DetIq2Rn.js.map → dist--NYpzm4f.js.map} +1 -1
- package/public/assets/{dist-dffB6laV.js → dist-BODiHbq1.js} +2 -2
- package/public/assets/{dist-dffB6laV.js.map → dist-BODiHbq1.js.map} +1 -1
- package/public/assets/{dist-B7m1nK9-.js → dist-C4VoXCT-.js} +2 -2
- package/public/assets/{dist-B7m1nK9-.js.map → dist-C4VoXCT-.js.map} +1 -1
- package/public/assets/dist-D3jA_LvQ.js +2 -0
- package/public/assets/{dist-CoqsUXVF.js.map → dist-D3jA_LvQ.js.map} +1 -1
- package/public/assets/{dist-C9UjQOV6.js → dist-DVorlUcA.js} +2 -2
- package/public/assets/{dist-C9UjQOV6.js.map → dist-DVorlUcA.js.map} +1 -1
- package/public/assets/{dist-CrI1Uij-.js → dist-DY3KCMIN.js} +2 -2
- package/public/assets/{dist-CrI1Uij-.js.map → dist-DY3KCMIN.js.map} +1 -1
- package/public/assets/{dist-CyQcAgI5.js → dist-DhEz6YNy.js} +2 -2
- package/public/assets/{dist-CyQcAgI5.js.map → dist-DhEz6YNy.js.map} +1 -1
- package/public/assets/dist-HTxIFS7t.js +2 -0
- package/public/assets/{dist-DHiVCawj.js.map → dist-HTxIFS7t.js.map} +1 -1
- package/public/assets/{dist-CzelKhUn.js → dist-_EunAAFX.js} +2 -2
- package/public/assets/{dist-CzelKhUn.js.map → dist-_EunAAFX.js.map} +1 -1
- package/public/assets/dist-rLCXIl7x.js +2 -0
- package/public/assets/{dist-DRTRN4My.js.map → dist-rLCXIl7x.js.map} +1 -1
- package/public/assets/{dist-BvF_x64R.js → dist-yzKYLD10.js} +2 -2
- package/public/assets/{dist-BvF_x64R.js.map → dist-yzKYLD10.js.map} +1 -1
- package/public/assets/{es2015-CBZid1bm.js → es2015-BDZowz0r.js} +2 -2
- package/public/assets/{es2015-CBZid1bm.js.map → es2015-BDZowz0r.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CEqSWNRL.js → formatted-body-impl-tIjgf9Kr.js} +2 -2
- package/public/assets/{formatted-body-impl-CEqSWNRL.js.map → formatted-body-impl-tIjgf9Kr.js.map} +1 -1
- package/public/assets/index-BfQFX8td.js +22 -0
- package/public/assets/{index-CtGaz-Yg.js.map → index-BfQFX8td.js.map} +1 -1
- package/public/assets/index-D8vc380x.css +2 -0
- package/public/assets/{input-C5FrEDmc.js → input-CD-ZcZIA.js} +2 -2
- package/public/assets/{input-C5FrEDmc.js.map → input-CD-ZcZIA.js.map} +1 -1
- package/public/assets/insights-B93PGmKq.js +2 -0
- package/public/assets/{insights-mf5sd483.js.map → insights-B93PGmKq.js.map} +1 -1
- package/public/assets/{integrations-4V9lOQ8b.js → integrations-BfWMAznM.js} +2 -2
- package/public/assets/{integrations-4V9lOQ8b.js.map → integrations-BfWMAznM.js.map} +1 -1
- package/public/assets/{lib-DjewNYeR.js → lib-BqL6WCDu.js} +2 -2
- package/public/assets/{lib-DjewNYeR.js.map → lib-BqL6WCDu.js.map} +1 -1
- package/public/assets/lucide-react-D78N6SwK.js +9 -0
- package/public/assets/lucide-react-D78N6SwK.js.map +1 -0
- package/public/assets/{maintenance-CruMfiA7.js → maintenance-CgRASTD0.js} +2 -2
- package/public/assets/{maintenance-CruMfiA7.js.map → maintenance-CgRASTD0.js.map} +1 -1
- package/public/assets/{managed-agents-fOhHGzNI.js → managed-agents-B1AdrEv4.js} +2 -2
- package/public/assets/{managed-agents-fOhHGzNI.js.map → managed-agents-B1AdrEv4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-g5-PTM1A.js → markdown-preview-impl-D2zEt-i-.js} +2 -2
- package/public/assets/{markdown-preview-impl-g5-PTM1A.js.map → markdown-preview-impl-D2zEt-i-.js.map} +1 -1
- package/public/assets/memory-nUBcij_9.js +2 -0
- package/public/assets/{memory-CRyq2WNC.js.map → memory-nUBcij_9.js.map} +1 -1
- package/public/assets/{messages-BqLp0X3y.js → messages-BNDs53bW.js} +2 -2
- package/public/assets/{messages-BqLp0X3y.js.map → messages-BNDs53bW.js.map} +1 -1
- package/public/assets/{orchestrators-BD4nZcG3.js → orchestrators-BouoI90a.js} +2 -2
- package/public/assets/{orchestrators-BD4nZcG3.js.map → orchestrators-BouoI90a.js.map} +1 -1
- package/public/assets/{overview-De-2GN06.js → overview-C8_hAGpC.js} +2 -2
- package/public/assets/{overview-De-2GN06.js.map → overview-C8_hAGpC.js.map} +1 -1
- package/public/assets/{pairs-DOqjBd0R.js → pairs-BcCOB1oO.js} +2 -2
- package/public/assets/{pairs-DOqjBd0R.js.map → pairs-BcCOB1oO.js.map} +1 -1
- package/public/assets/projects-MZ6CNrYO.js +2 -0
- package/public/assets/{projects-Byp_gLoD.js.map → projects-MZ6CNrYO.js.map} +1 -1
- package/public/assets/{react-dom--5xRjaey.js → react-dom-CU0WCHqq.js} +2 -2
- package/public/assets/{react-dom--5xRjaey.js.map → react-dom-CU0WCHqq.js.map} +1 -1
- package/public/assets/{security-DtoRSpIi.js → security-Bh8BGpZR.js} +2 -2
- package/public/assets/{security-DtoRSpIi.js.map → security-Bh8BGpZR.js.map} +1 -1
- package/public/assets/{select-esP750zQ.js → select-Drdvachg.js} +2 -2
- package/public/assets/{select-esP750zQ.js.map → select-Drdvachg.js.map} +1 -1
- package/public/assets/settings-IrNBT0hy.js +4 -0
- package/public/assets/{settings-U8788lYt.js.map → settings-IrNBT0hy.js.map} +1 -1
- package/public/assets/store-CqFZmc-B.js +9 -0
- package/public/assets/store-CqFZmc-B.js.map +1 -0
- package/public/assets/{tasks-CtiwQy9I.js → tasks-heEgx7vT.js} +2 -2
- package/public/assets/{tasks-CtiwQy9I.js.map → tasks-heEgx7vT.js.map} +1 -1
- package/public/assets/teams-BjETr-_W.js +2 -0
- package/public/assets/teams-BjETr-_W.js.map +1 -0
- package/public/assets/{terminal-viewer-impl-h9hvvB9S.js → terminal-viewer-impl-BDqmbWB3.js} +3 -3
- package/public/assets/{terminal-viewer-impl-h9hvvB9S.js.map → terminal-viewer-impl-BDqmbWB3.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-vC7NeLD9.js → use-keyboard-viewport-CichJ8Jn.js} +2 -2
- package/public/assets/{use-keyboard-viewport-vC7NeLD9.js.map → use-keyboard-viewport-CichJ8Jn.js.map} +1 -1
- package/public/assets/{work-queue-C-bS6YmI.js → work-queue-rgQs4Wis.js} +2 -2
- package/public/assets/{work-queue-C-bS6YmI.js.map → work-queue-rgQs4Wis.js.map} +1 -1
- package/public/assets/workspaces-hIjZicaF.js +3 -0
- package/public/assets/{workspaces-Bivhqw2N.js.map → workspaces-hIjZicaF.js.map} +1 -1
- package/public/index.html +22 -22
- package/src/agent-authz.ts +169 -0
- package/src/agent-ref.ts +7 -3
- package/src/dashboard-presence.ts +91 -0
- package/src/db/agent-ownership.ts +168 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +17 -0
- package/src/db/schema.ts +16 -0
- package/src/db/teams.ts +13 -4
- package/src/db/users.ts +120 -0
- package/src/maintenance/teams.ts +1 -7
- package/src/routes/_shared.ts +75 -1
- package/src/routes/agent-sessions.ts +57 -6
- package/src/routes/agents.ts +13 -4
- package/src/routes/index.ts +6 -1
- package/src/routes/settings.ts +33 -8
- package/src/routes/teams.ts +11 -4
- package/src/routes/users.ts +8 -0
- package/src/services/register-agent.ts +11 -1
- package/src/services/send-message.ts +7 -1
- package/src/services/team.ts +17 -4
- package/src/services/transient-agent-reaper.ts +36 -0
- package/src/settings/registry.ts +63 -0
- package/src/sse.ts +7 -0
- package/src/team-idle-tracker.ts +13 -0
- package/src/user-settings-store.ts +86 -0
- package/public/assets/MessageBubble-C-3IcqgU.js +0 -3
- package/public/assets/MessageBubble-C-3IcqgU.js.map +0 -1
- package/public/assets/agents-DAdDtQb6.js +0 -2
- package/public/assets/chat-DDlRjwGC.js +0 -2
- package/public/assets/chat-DDlRjwGC.js.map +0 -1
- package/public/assets/display-DHgdn4vN.js +0 -3
- package/public/assets/display-DHgdn4vN.js.map +0 -1
- package/public/assets/dist-CoqsUXVF.js +0 -2
- package/public/assets/dist-DHiVCawj.js +0 -2
- package/public/assets/dist-DRTRN4My.js +0 -2
- package/public/assets/index-BpsfMZ3T.css +0 -2
- package/public/assets/index-CtGaz-Yg.js +0 -22
- package/public/assets/insights-mf5sd483.js +0 -2
- package/public/assets/lucide-react-CNGB8fo1.js +0 -9
- package/public/assets/lucide-react-CNGB8fo1.js.map +0 -1
- package/public/assets/memory-CRyq2WNC.js +0 -2
- package/public/assets/projects-Byp_gLoD.js +0 -2
- package/public/assets/settings-U8788lYt.js +0 -4
- package/public/assets/store-Cjcd0egy.js +0 -9
- package/public/assets/store-Cjcd0egy.js.map +0 -1
- package/public/assets/teams-Cx29VUif.js +0 -2
- package/public/assets/teams-Cx29VUif.js.map +0 -1
- package/public/assets/workspaces-Bivhqw2N.js +0 -3
package/src/db/users.ts
ADDED
|
@@ -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
|
+
}
|
package/src/maintenance/teams.ts
CHANGED
|
@@ -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();
|
package/src/routes/_shared.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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!);
|
package/src/routes/agents.ts
CHANGED
|
@@ -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
|
-
|
|
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 = (
|
|
63
|
+
export const getAgentById: Handler = (req, params) => {
|
|
60
64
|
const agent = getAgent(params.id!);
|
|
61
|
-
|
|
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");
|
package/src/routes/index.ts
CHANGED
|
@@ -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),
|
package/src/routes/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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:
|
|
120
|
+
writable: mutableTier && canAccessSetting(req, descriptor.scope.write),
|
|
100
121
|
};
|
|
101
122
|
}
|
|
102
123
|
|
|
103
|
-
|
|
104
|
-
|
|
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,
|
package/src/routes/teams.ts
CHANGED
|
@@ -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
|
|
47
|
-
|
|
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 {
|
|
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`.
|