agent-relay-server 0.81.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-C_Qm7s4q.js → PlanGraphPanel-r3aVT4uc.js} +2 -2
- package/public/assets/{PlanGraphPanel-C_Qm7s4q.js.map → PlanGraphPanel-r3aVT4uc.js.map} +1 -1
- package/public/assets/{activity-HoO4RRm1.js → activity-DtMjjws8.js} +2 -2
- package/public/assets/{activity-HoO4RRm1.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-CSYMFVpW.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-Bj2AYEw0.js → automation-CxwGXWC7.js} +2 -2
- package/public/assets/{automation-Bj2AYEw0.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-CzKd4psz.js → formatted-body-impl-tIjgf9Kr.js} +2 -2
- package/public/assets/{formatted-body-impl-CzKd4psz.js.map → formatted-body-impl-tIjgf9Kr.js.map} +1 -1
- package/public/assets/index-BfQFX8td.js +22 -0
- package/public/assets/{index-DW4YIA7L.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-DgGTwAwZ.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-CmgIgjo4.js → maintenance-CgRASTD0.js} +2 -2
- package/public/assets/{maintenance-CmgIgjo4.js.map → maintenance-CgRASTD0.js.map} +1 -1
- package/public/assets/{managed-agents-DRxAGFUB.js → managed-agents-B1AdrEv4.js} +2 -2
- package/public/assets/{managed-agents-DRxAGFUB.js.map → managed-agents-B1AdrEv4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DSquGj2d.js → markdown-preview-impl-D2zEt-i-.js} +2 -2
- package/public/assets/{markdown-preview-impl-DSquGj2d.js.map → markdown-preview-impl-D2zEt-i-.js.map} +1 -1
- package/public/assets/memory-nUBcij_9.js +2 -0
- package/public/assets/{memory-BYq4eEF_.js.map → memory-nUBcij_9.js.map} +1 -1
- package/public/assets/{messages-BZt9N6sy.js → messages-BNDs53bW.js} +2 -2
- package/public/assets/{messages-BZt9N6sy.js.map → messages-BNDs53bW.js.map} +1 -1
- package/public/assets/{orchestrators-DoqpuP2u.js → orchestrators-BouoI90a.js} +2 -2
- package/public/assets/{orchestrators-DoqpuP2u.js.map → orchestrators-BouoI90a.js.map} +1 -1
- package/public/assets/{overview-CtpqcW4U.js → overview-C8_hAGpC.js} +2 -2
- package/public/assets/{overview-CtpqcW4U.js.map → overview-C8_hAGpC.js.map} +1 -1
- package/public/assets/{pairs-XoWsTxhe.js → pairs-BcCOB1oO.js} +2 -2
- package/public/assets/{pairs-XoWsTxhe.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-B_C5yJi5.js → security-Bh8BGpZR.js} +2 -2
- package/public/assets/{security-B_C5yJi5.js.map → security-Bh8BGpZR.js.map} +1 -1
- package/public/assets/{select-BBZ8pgCn.js → select-Drdvachg.js} +2 -2
- package/public/assets/{select-BBZ8pgCn.js.map → select-Drdvachg.js.map} +1 -1
- package/public/assets/settings-IrNBT0hy.js +4 -0
- package/public/assets/{settings-DNYEfnw6.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-Bww7kqYc.js → tasks-heEgx7vT.js} +2 -2
- package/public/assets/{tasks-Bww7kqYc.js.map → tasks-heEgx7vT.js.map} +1 -1
- package/public/assets/teams-BjETr-_W.js +2 -0
- package/public/assets/{teams-C6B038l0.js.map → teams-BjETr-_W.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CvoK2Byo.js → terminal-viewer-impl-BDqmbWB3.js} +3 -3
- package/public/assets/{terminal-viewer-impl-CvoK2Byo.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-w_eT-XFN.js → work-queue-rgQs4Wis.js} +2 -2
- package/public/assets/{work-queue-w_eT-XFN.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/users.ts +120 -0
- 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/users.ts +8 -0
- package/src/services/register-agent.ts +11 -1
- package/src/services/send-message.ts +7 -1
- package/src/settings/registry.ts +63 -0
- package/src/sse.ts +7 -0
- package/src/user-settings-store.ts +86 -0
- package/public/assets/MessageBubble-DcSxvVGQ.js +0 -3
- package/public/assets/MessageBubble-DcSxvVGQ.js.map +0 -1
- package/public/assets/agents-CSYMFVpW.js +0 -2
- package/public/assets/chat-C410UoF1.js +0 -2
- package/public/assets/chat-C410UoF1.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-DW4YIA7L.js +0 -22
- package/public/assets/insights-DgGTwAwZ.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-BYq4eEF_.js +0 -2
- package/public/assets/projects-Byp_gLoD.js +0 -2
- package/public/assets/settings-DNYEfnw6.js +0 -4
- package/public/assets/store-Cjcd0egy.js +0 -9
- package/public/assets/store-Cjcd0egy.js.map +0 -1
- package/public/assets/teams-C6B038l0.js +0 -2
- package/public/assets/workspaces-Bivhqw2N.js +0 -3
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,
|
|
@@ -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`.
|
package/src/settings/registry.ts
CHANGED
|
@@ -241,6 +241,41 @@ const LANDING_SCHEMA: JSONSchema = {
|
|
|
241
241
|
},
|
|
242
242
|
};
|
|
243
243
|
|
|
244
|
+
// Per-user preference schemas (#391, tier:"user"). These follow the human across devices via the
|
|
245
|
+
// `user_settings` table (keyed by `users.id`), replacing per-browser localStorage for genuinely
|
|
246
|
+
// user-level prefs. Field names mirror the dashboard store (dashboard/src/store) so the client
|
|
247
|
+
// migration is a lift, not a rename. Truly device-local view state stays in the browser.
|
|
248
|
+
const DISPLAY_PREFS_SCHEMA: JSONSchema = {
|
|
249
|
+
type: "object",
|
|
250
|
+
additionalProperties: false,
|
|
251
|
+
required: ["theme", "showReasoning"],
|
|
252
|
+
properties: {
|
|
253
|
+
theme: { type: "string", enum: ["light", "dark", "midnight", "nord", "ember", "parchment"] },
|
|
254
|
+
showReasoning: { type: "boolean" },
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const VOICE_PREFS_SCHEMA: JSONSchema = {
|
|
259
|
+
type: "object",
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
required: [
|
|
262
|
+
"voiceTtsEnabled",
|
|
263
|
+
"voiceTtsLang",
|
|
264
|
+
"voiceTtsMode",
|
|
265
|
+
"voiceTtsKokoroVoice",
|
|
266
|
+
"voiceTtsBrowserVoice",
|
|
267
|
+
"voiceInputMode",
|
|
268
|
+
],
|
|
269
|
+
properties: {
|
|
270
|
+
voiceTtsEnabled: { type: "boolean" },
|
|
271
|
+
voiceTtsLang: { type: "string", maxLength: 35 },
|
|
272
|
+
voiceTtsMode: { type: "string", enum: ["kokoro", "browser"] },
|
|
273
|
+
voiceTtsKokoroVoice: { type: "string", maxLength: 80 },
|
|
274
|
+
voiceTtsBrowserVoice: { type: "string", maxLength: 200 },
|
|
275
|
+
voiceInputMode: { type: "string", enum: ["compose", "autosend", "handsfree"] },
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
244
279
|
// Provider chat/reasoning capture toggles (source: runner/src/adapter.ts ProviderConfig).
|
|
245
280
|
// NOTE (#383 coordination): provider config storage is being centralized in #383;
|
|
246
281
|
// these are seeded instance-global under namespace "provider". Per-provider
|
|
@@ -393,6 +428,34 @@ function seedBuiltInSettings(): void {
|
|
|
393
428
|
tier: "server-runtime",
|
|
394
429
|
display: { group: "Providers", label: "Reasoning capture", description: "Stream reasoning/tool steps into chat.", order: 51 },
|
|
395
430
|
});
|
|
431
|
+
|
|
432
|
+
// Per-user prefs (#391) — tier:"user", stored per `users.id` so they follow the human across devices.
|
|
433
|
+
registerSetting({
|
|
434
|
+
namespace: "display",
|
|
435
|
+
key: "default",
|
|
436
|
+
schema: DISPLAY_PREFS_SCHEMA,
|
|
437
|
+
defaults: { theme: "dark", showReasoning: false },
|
|
438
|
+
scope: { read: "settings:read:display", write: "settings:write:display" },
|
|
439
|
+
tier: "user",
|
|
440
|
+
display: { group: "Preferences", label: "Display", description: "Per-user theme and reasoning visibility (follows you across devices).", order: 70 },
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
registerSetting({
|
|
444
|
+
namespace: "voice",
|
|
445
|
+
key: "default",
|
|
446
|
+
schema: VOICE_PREFS_SCHEMA,
|
|
447
|
+
defaults: {
|
|
448
|
+
voiceTtsEnabled: false,
|
|
449
|
+
voiceTtsLang: "en-US",
|
|
450
|
+
voiceTtsMode: "kokoro",
|
|
451
|
+
voiceTtsKokoroVoice: "am_michael",
|
|
452
|
+
voiceTtsBrowserVoice: "",
|
|
453
|
+
voiceInputMode: "compose",
|
|
454
|
+
},
|
|
455
|
+
scope: { read: "settings:read:voice", write: "settings:write:voice" },
|
|
456
|
+
tier: "user",
|
|
457
|
+
display: { group: "Preferences", label: "Voice", description: "Per-user text-to-speech and voice-input preferences.", order: 71 },
|
|
458
|
+
});
|
|
396
459
|
}
|
|
397
460
|
|
|
398
461
|
export function registerProviderConfigSetting(input: {
|
package/src/sse.ts
CHANGED
|
@@ -233,6 +233,13 @@ export function getConnectionCount(): number {
|
|
|
233
233
|
return connections.size;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// Dashboard viewer presence for an agent thread (#390): broadcast the live set of humans
|
|
237
|
+
// currently viewing/typing so every open dashboard can show "X is viewing". Ephemeral — the
|
|
238
|
+
// viewer list comes from the in-memory presence registry, not the DB.
|
|
239
|
+
export function emitDashboardPresence(agentId: string, viewers: unknown) {
|
|
240
|
+
emitRelayEvent({ type: "dashboard.presence", source: "server", subject: agentId, data: { agentId, viewers } });
|
|
241
|
+
}
|
|
242
|
+
|
|
236
243
|
export function emitOrchestratorStatus(orchestratorId: string) {
|
|
237
244
|
const orch = getOrchestrator(orchestratorId);
|
|
238
245
|
const data = orch ?? { id: orchestratorId, status: "offline" };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Per-user settings storage (#391, epic #388). The user dimension the global `config`
|
|
2
|
+
// table lacks: `tier:"user"` descriptors (settings/registry.ts) resolve the SAME
|
|
3
|
+
// `(namespace, key)` address to a per-user value, keyed by the resolved `users.id` (#393),
|
|
4
|
+
// so a human's pref follows them across devices. This is the storage seam ONLY — schema
|
|
5
|
+
// validation + scope enforcement stay in the registry/route; this module just persists the
|
|
6
|
+
// per-user JSON value and mirrors the `config`-table ConfigEntry shape so the settings API
|
|
7
|
+
// treats user-tier and server-runtime values uniformly.
|
|
8
|
+
//
|
|
9
|
+
// Deliberately NOT folded into config-store.ts: the global `config` table keeps its exact
|
|
10
|
+
// `(namespace, key)` PK + history semantics untouched (#391 back-compat), and the
|
|
11
|
+
// transport-thinness/file-size ratchets keep config-store as the instance-global home. Per-user
|
|
12
|
+
// prefs are low-stakes display state, so no config_history mirror — last-write-wins with a
|
|
13
|
+
// monotonic version is enough.
|
|
14
|
+
|
|
15
|
+
import { getDb } from "./db";
|
|
16
|
+
import type { ConfigEntry } from "agent-relay-sdk";
|
|
17
|
+
|
|
18
|
+
interface UserSettingRow {
|
|
19
|
+
user_id: string;
|
|
20
|
+
namespace: string;
|
|
21
|
+
key: string;
|
|
22
|
+
value: string;
|
|
23
|
+
version: number;
|
|
24
|
+
updated_at: string;
|
|
25
|
+
updated_by: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rowToConfigEntry<T>(row: UserSettingRow): ConfigEntry<T> {
|
|
29
|
+
return {
|
|
30
|
+
namespace: row.namespace,
|
|
31
|
+
key: row.key,
|
|
32
|
+
value: JSON.parse(row.value) as T,
|
|
33
|
+
version: row.version,
|
|
34
|
+
updatedAt: row.updated_at,
|
|
35
|
+
updatedBy: row.updated_by ?? undefined,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A single user's stored value for a `tier:"user"` descriptor, or null when unset (→ defaults). */
|
|
40
|
+
export function getUserSetting<T = unknown>(userId: string, namespace: string, key: string): ConfigEntry<T> | null {
|
|
41
|
+
const row = getDb()
|
|
42
|
+
.query("SELECT * FROM user_settings WHERE user_id = ? AND namespace = ? AND key = ?")
|
|
43
|
+
.get(userId, namespace, key) as UserSettingRow | undefined;
|
|
44
|
+
return row ? rowToConfigEntry<T>(row) : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** All of a user's stored values in a namespace (ascending key). */
|
|
48
|
+
export function listUserSettings<T = unknown>(userId: string, namespace: string): ConfigEntry<T>[] {
|
|
49
|
+
const rows = getDb()
|
|
50
|
+
.query("SELECT * FROM user_settings WHERE user_id = ? AND namespace = ? ORDER BY key ASC")
|
|
51
|
+
.all(userId, namespace) as UserSettingRow[];
|
|
52
|
+
return rows.map(rowToConfigEntry<T>);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Upsert a user's value for a descriptor. Value is persisted verbatim — validate via the registry first. */
|
|
56
|
+
export function setUserSetting<T = unknown>(
|
|
57
|
+
userId: string,
|
|
58
|
+
namespace: string,
|
|
59
|
+
key: string,
|
|
60
|
+
value: T,
|
|
61
|
+
updatedBy?: string,
|
|
62
|
+
): ConfigEntry<T> {
|
|
63
|
+
const existing = getUserSetting(userId, namespace, key);
|
|
64
|
+
const now = new Date().toISOString();
|
|
65
|
+
const nextVersion = existing ? existing.version + 1 : 1;
|
|
66
|
+
getDb()
|
|
67
|
+
.query(`
|
|
68
|
+
INSERT INTO user_settings (user_id, namespace, key, value, version, updated_at, updated_by)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
70
|
+
ON CONFLICT(user_id, namespace, key) DO UPDATE SET
|
|
71
|
+
value = excluded.value,
|
|
72
|
+
version = excluded.version,
|
|
73
|
+
updated_at = excluded.updated_at,
|
|
74
|
+
updated_by = excluded.updated_by
|
|
75
|
+
`)
|
|
76
|
+
.run(userId, namespace, key, JSON.stringify(value), nextVersion, now, updatedBy ?? null);
|
|
77
|
+
return getUserSetting<T>(userId, namespace, key)!;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Drop a user's stored value (reverts to descriptor defaults). Returns false when nothing was stored. */
|
|
81
|
+
export function deleteUserSetting(userId: string, namespace: string, key: string): boolean {
|
|
82
|
+
const existing = getUserSetting(userId, namespace, key);
|
|
83
|
+
if (!existing) return false;
|
|
84
|
+
getDb().query("DELETE FROM user_settings WHERE user_id = ? AND namespace = ? AND key = ?").run(userId, namespace, key);
|
|
85
|
+
return true;
|
|
86
|
+
}
|