agent-relay-server 0.36.2 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +1 -1
  3. package/public/assets/{activity-C3mkM6AU.js → activity-BgkmA1lh.js} +2 -2
  4. package/public/assets/{activity-C3mkM6AU.js.map → activity-BgkmA1lh.js.map} +1 -1
  5. package/public/assets/{agents-CAhQO7JH.js → agents-B7HnuAXx.js} +2 -2
  6. package/public/assets/{agents-CAhQO7JH.js.map → agents-B7HnuAXx.js.map} +1 -1
  7. package/public/assets/{analytics-BwihhhNn.js → analytics-0-akxJCJ.js} +2 -2
  8. package/public/assets/{analytics-BwihhhNn.js.map → analytics-0-akxJCJ.js.map} +1 -1
  9. package/public/assets/{automation-BLXToUiU.js → automation-CaE1z_-M.js} +2 -2
  10. package/public/assets/{automation-BLXToUiU.js.map → automation-CaE1z_-M.js.map} +1 -1
  11. package/public/assets/{chat-8iIPyww9.js → chat-BANKUW05.js} +2 -2
  12. package/public/assets/{chat-8iIPyww9.js.map → chat-BANKUW05.js.map} +1 -1
  13. package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-DExNPNsL.js} +2 -2
  14. package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-DExNPNsL.js.map} +1 -1
  15. package/public/assets/{index-CaauKXl9.js → index-DEZdON6c.js} +5 -5
  16. package/public/assets/{index-CaauKXl9.js.map → index-DEZdON6c.js.map} +1 -1
  17. package/public/assets/{maintenance-9n_rJCHT.js → maintenance-DpTdJxQp.js} +2 -2
  18. package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-DpTdJxQp.js.map} +1 -1
  19. package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-B3df2xfk.js} +2 -2
  20. package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-B3df2xfk.js.map} +1 -1
  21. package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D0Zj7c3T.js} +2 -2
  22. package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D0Zj7c3T.js.map} +1 -1
  23. package/public/assets/{memory-BQONtGQS.js → memory-TATN2vZf.js} +2 -2
  24. package/public/assets/{memory-BQONtGQS.js.map → memory-TATN2vZf.js.map} +1 -1
  25. package/public/assets/{messages-DGqpkH72.js → messages-3rS1lxIf.js} +2 -2
  26. package/public/assets/{messages-DGqpkH72.js.map → messages-3rS1lxIf.js.map} +1 -1
  27. package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CRIV0g5y.js} +2 -2
  28. package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CRIV0g5y.js.map} +1 -1
  29. package/public/assets/{overview-DSU_CggA.js → overview-CmHC_5oM.js} +2 -2
  30. package/public/assets/{overview-DSU_CggA.js.map → overview-CmHC_5oM.js.map} +1 -1
  31. package/public/assets/{pairs-DGocNC1U.js → pairs-DBPAhXTI.js} +2 -2
  32. package/public/assets/{pairs-DGocNC1U.js.map → pairs-DBPAhXTI.js.map} +1 -1
  33. package/public/assets/{security-BSh0QxOl.js → security-572X5MNX.js} +2 -2
  34. package/public/assets/{security-BSh0QxOl.js.map → security-572X5MNX.js.map} +1 -1
  35. package/public/assets/{settings-C03CAJgO.js → settings-vTBu8w3O.js} +2 -2
  36. package/public/assets/{settings-C03CAJgO.js.map → settings-vTBu8w3O.js.map} +1 -1
  37. package/public/assets/{tasks-rKbuUPOk.js → tasks-C0bPrDgN.js} +2 -2
  38. package/public/assets/{tasks-rKbuUPOk.js.map → tasks-C0bPrDgN.js.map} +1 -1
  39. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-rVPA6Fsx.js} +2 -2
  40. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-rVPA6Fsx.js.map} +1 -1
  41. package/public/assets/{work-queue-DOsA9s4M.js → work-queue-BxkpTt_A.js} +2 -2
  42. package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-BxkpTt_A.js.map} +1 -1
  43. package/public/index.html +1 -1
  44. package/runner/src/adapter.ts +7 -0
  45. package/scripts/orchestrator-spawn-smoke.ts +65 -33
  46. package/src/agent-ref.ts +28 -1
  47. package/src/bus.ts +52 -41
  48. package/src/compaction-watch.ts +7 -0
  49. package/src/index.ts +23 -6
  50. package/src/lifecycle-manager.ts +33 -71
  51. package/src/mcp.ts +100 -308
  52. package/src/routes/agent-sessions.ts +38 -3
  53. package/src/routes/agents-spawn.ts +43 -174
  54. package/src/routes/commands.ts +7 -19
  55. package/src/routes/messages.ts +24 -87
  56. package/src/security.ts +7 -0
  57. package/src/services/auth-context.ts +109 -0
  58. package/src/services/dispatch-command.ts +60 -0
  59. package/src/services/errors.ts +26 -0
  60. package/src/services/managed-running.ts +130 -0
  61. package/src/services/parity-harness.ts +135 -0
  62. package/src/services/register-agent.ts +74 -0
  63. package/src/services/send-message.ts +159 -0
  64. package/src/services/shutdown-agent.ts +234 -0
  65. package/src/services/spawn-agent.ts +278 -0
@@ -1,21 +1,14 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
2
  import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
- import { McpNotFoundError } from "../mcp-errors";
4
- import { selectSpawnOrchestrator } from "../spawn-targets";
5
- import { VALID_AGENT_STATUSES, auditEvent, authAuditMetadata, authorizeRoute, emitCommand, error, json, metaString, parseBody, spawnRequestId, type Handler } from "./_shared";
6
- import { ValidationError, getAgent, getOrchestrator, listAgents, resolveQueuedPolicyMessages, upsertAgent } from "../db";
7
- import { buildSpawnCommand, resolveSpawnModelParams, type SpawnModelParams } from "../spawn-command";
3
+ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
+ import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
5
+ import { ValidationError, listAgents } from "../db";
8
6
  import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optionalEnum } from "../validation";
9
- import { createCommand } from "../commands-db";
10
- import { emitAgentStatus, emitManagedAgentStateChanged, emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
11
- import { getAgentProfile, getManagedAgentState, updateManagedAgentState } from "../config-store";
12
- import { notifyAgentReady } from "../agent-lifecycle-events";
13
- import { getCompactionWatch } from "../compaction-watch";
14
- import { getComponentAuth, resolveSpawnLineage } from "../security";
15
- import { isPathWithinBase } from "../utils";
7
+ import { registerAgent } from "../services/register-agent";
8
+ import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
9
+ import { authContextFromRequest } from "../services/auth-context";
16
10
  import { listHostDirectories } from "../agent-spawn";
17
11
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
18
- import { runnerRuntimeTokenEnv } from "../runtime-tokens";
19
12
  import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceMode } from "../types";
20
13
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
21
14
 
@@ -63,70 +56,12 @@ export const postAgent: Handler = async (req) => {
63
56
  const parsed = await parseBody<unknown>(req);
64
57
  if (!parsed.ok) return error(parsed.error, parsed.status);
65
58
  try {
59
+ // Thin transport: parse wire → build AuthContext → call the registerAgent service →
60
+ // serialize. ALL policy + side effects (lineage, managed came-up-running, status emit,
61
+ // timeline note, parent-wake, audit) live in src/services/register-agent.ts so the HTTP,
62
+ // bus, and orchestrator-report paths cannot drift (epic #342).
66
63
  const input = normalizeAgentInput(parsed.body);
67
- // Lineage is authoritative from the registering token's signed constraints — never the
68
- // client-sent body (a child can't forge its own parent). Set by relay at spawn for
69
- // agent-initiated spawns (`spawnedBy`) and the delegating-component path (`parentAgents`).
70
- const lineage = resolveSpawnLineage(getComponentAuth(req)?.constraints);
71
- if (lineage) input.spawnedBy = lineage;
72
- const existing = getAgent(input.id);
73
- const agent = upsertAgent(input);
74
- const policyName = metaString(agent.meta, "policyName");
75
- const spawnRequestId = metaString(agent.meta, "spawnRequestId");
76
- const tmuxSession = metaString(agent.meta, "tmuxSession");
77
- if (policyName && spawnRequestId) {
78
- const state = getManagedAgentState(policyName);
79
- if (state?.spawnRequestId === spawnRequestId) {
80
- const updatedState = updateManagedAgentState(policyName, {
81
- status: "running",
82
- agentId: agent.id,
83
- tmuxSession: tmuxSession ?? state.tmuxSession,
84
- healthySince: Date.now(),
85
- lastError: undefined,
86
- });
87
- if (updatedState) emitManagedAgentStateChanged(policyName, updatedState as unknown as Record<string, unknown>);
88
- const available = resolveQueuedPolicyMessages(policyName, agent.id);
89
- if (available.length) {
90
- emitMessageAvailable(policyName, agent.id, available);
91
- for (const message of available) {
92
- emitNewMessage(message);
93
- // queued → pending flips delivery_status; the dashboard dedups message.new
94
- // by id (the message already shows as "queued"), so without an explicit
95
- // delivery_updated the badge stays stale until the next poll (#265).
96
- emitMessageDeliveryUpdated(message);
97
- }
98
- }
99
- }
100
- }
101
- emitAgentStatus(agent.id);
102
- // #308 — a spawned child registering already-ready (the common isolated-worktree case: it
103
- // comes up ready+idle) is genuinely ready; wake its parent so it can stop block-polling.
104
- // Fire on the first-ever ready (no prior record, or prior record wasn't ready yet) — the
105
- // ready/false→true flip path is handled in patchAgentReady. notifyAgentReady no-ops for
106
- // non-spawned agents and dedups repeats.
107
- if (agent.ready && !existing?.ready) notifyAgentReady(agent.id);
108
- // A real PreCompact / SessionStart(clear) hook reports progress via the
109
- // agent's timelineEvent — clears any pending stall watch for this agent.
110
- // timelineEvent is latched, so pass its timestamp: only a fresh event
111
- // (stamped after the watch armed) counts as this command's real start.
112
- const timelineEvent = isRecord(agent.meta?.timelineEvent) ? agent.meta.timelineEvent : undefined;
113
- const timelineStatus = metaString(timelineEvent, "status");
114
- if (timelineStatus) {
115
- const timelineTs = typeof timelineEvent?.timestamp === "number" ? timelineEvent.timestamp : undefined;
116
- getCompactionWatch().noteTimelineStatus(agent.id, timelineStatus, timelineTs);
117
- }
118
- if (!existing) {
119
- auditEvent({
120
- clientId: "server-agent-" + agent.id + "-registered",
121
- kind: "state",
122
- title: "Agent registered",
123
- body: agent.name,
124
- meta: agent.id,
125
- icon: "ti-robot",
126
- view: "agents",
127
- agentId: agent.id,
128
- });
129
- }
64
+ const agent = registerAgent(input, authContextFromRequest(req));
130
65
  return json(agent, 201);
131
66
  } catch (e) {
132
67
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -162,83 +97,39 @@ export const postAgentSpawn: Handler = async (req) => {
162
97
  if (!isRecord(parsed.body)) return error("provider required");
163
98
  const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider | undefined;
164
99
  if (!provider) return error("provider required");
165
- const orchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 });
166
- const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
167
- // Resolves an explicit host, else the host whose baseDir contains cwd, else the lone candidate
168
- // (the same resolver the MCP spawn tool uses one home for host selection).
169
- const orch = selectSpawnOrchestrator(provider, orchestratorId, cwd);
170
- if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
171
- return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
172
- }
173
- const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
174
- if (selection instanceof Response) return selection;
175
- const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
176
- const label = cleanString(parsed.body.label, "label", { max: 120 });
177
- const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
178
- const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
179
- const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
180
- const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
181
- const tags = cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) ?? [];
182
- const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }) ?? [];
183
- const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }) ?? [];
184
- const agentProfile = profile ? getAgentProfile(profile)?.value : undefined;
185
- if (profile && !agentProfile) return error("agent profile not found", 404);
186
- const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
187
- const requestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }) ?? spawnRequestId();
188
- const denied = authorizeRoute(req, {
189
- scope: "agent:write",
190
- resource: { orchestratorId: orch.id, cwd: cwd || orch.baseDir, policyName, spawnRequestId: requestId },
191
- });
192
- if (denied) return denied;
193
- const command = createCommand({
194
- type: "agent.spawn",
195
- source: "system",
196
- target: orch.agentId,
197
- correlationId: requestId,
198
- params: buildSpawnCommand({
199
- provider,
200
- modelParams: selection,
201
- cwd: cwd || orch.baseDir,
202
- workspaceMode,
203
- label,
204
- tags,
205
- capabilities,
206
- approvalMode,
207
- permissionMode: approvalMode,
208
- profile: profile || undefined,
209
- agentProfile,
210
- providerArgs,
211
- prompt,
212
- systemPromptAppend,
213
- policyName,
214
- spawnRequestId: requestId,
215
- env: runnerRuntimeTokenEnv({
216
- orchestratorId: orch.id,
217
- cwd: cwd || orch.baseDir,
218
- provider,
219
- label,
220
- policyName,
221
- spawnRequestId: requestId,
222
- createdBy: "dashboard",
223
- profile: profile || undefined,
224
- }),
225
- requestedBy: "dashboard",
226
- requestedAt: Date.now(),
227
- }),
228
- });
229
- emitCommand(command);
230
- auditEvent({
231
- clientId: "server-agent-spawn-" + provider + "-" + command.id,
232
- kind: "state",
233
- title: `${provider} agent spawn requested (via ${orch.id})`,
234
- body: cwd || orch.baseDir,
235
- meta: orch.id,
236
- icon: "ti-plus",
237
- view: "agents",
238
- metadata: { provider, orchestratorId: orch.id, approvalMode, workspaceMode, label, commandId: command.id, ...authAuditMetadata(req) },
239
- });
240
- return json({ ok: true, orchestratorId: orch.id, provider, command }, 202);
100
+ // Thin transport: parse wire AuthContext spawnAgent service → serialize. The gate
101
+ // (command:spawn scope, #221 no-grandchild + quota, #323 resource), caller-context
102
+ // inheritance (#328/#331/#324), profile validation, lineage stamping, host selection, and
103
+ // the command payload all live in src/services/spawn-agent.ts so HTTP and MCP cannot drift
104
+ // (epic #342, #349). The dashboard authenticates with an admin `*` token, so the tightened
105
+ // command:spawn requirement is transparent to it.
106
+ const input: SpawnAgentInput = {
107
+ provider,
108
+ orchestratorId: cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 }),
109
+ cwd: cleanString(parsed.body.cwd, "cwd", { max: 500 }),
110
+ model: cleanString(parsed.body.model, "model", { max: 120 }),
111
+ effort: optionalEnum(parsed.body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
112
+ approvalMode: optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
113
+ workspaceMode: optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
114
+ label: cleanString(parsed.body.label, "label", { max: 120 }),
115
+ policyName: cleanString(parsed.body.policyName, "policyName", { max: 120 }),
116
+ profile: cleanString(parsed.body.profile, "profile", { max: 120 }),
117
+ prompt: cleanString(parsed.body.prompt, "prompt", { max: 16000 }),
118
+ systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
119
+ tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
120
+ capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
121
+ providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
122
+ spawnRequestId: cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }),
123
+ requestedVia: "dashboard",
124
+ requestedBy: "dashboard",
125
+ // HTTP is fire-and-forget (202): the dashboard polls registration separately via the
126
+ // returned spawnRequestId.
127
+ waitForRegistrationMs: 0,
128
+ };
129
+ const result = await spawnAgent(input, authContextFromRequest(req));
130
+ return json({ ok: true, orchestratorId: result.orchestratorId, provider: result.provider, command: result.command }, 202);
241
131
  } catch (e) {
132
+ if (e instanceof McpAuthError) return error(e.message, 403);
242
133
  if (e instanceof McpNotFoundError) return error(e.message, 404);
243
134
  if (e instanceof ValidationError) return error(e.message, 400);
244
135
  if (e instanceof Error) return error(e.message, 400);
@@ -257,25 +148,3 @@ export const getHostDirectories: Handler = (req) => {
257
148
  throw e;
258
149
  }
259
150
  };
260
-
261
- function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
262
- const model = cleanString(body.model, "model", { max: 120 });
263
- const effort = optionalEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
264
- return resolveSpawnModelParams(provider, model, effort);
265
- }
266
-
267
- function validateSpawnSelectionForOrchestrator(
268
- orch: NonNullable<ReturnType<typeof getOrchestrator>>,
269
- provider: SpawnProvider,
270
- body: Record<string, unknown>,
271
- ): SpawnModelParams | Response {
272
- if (!orch.providers.includes(provider)) {
273
- return error(`orchestrator does not have provider available: ${provider}`, 409);
274
- }
275
- try {
276
- return cleanSpawnSelection(body, provider);
277
- } catch (e) {
278
- if (e instanceof Error) return error(e.message, 400);
279
- throw e;
280
- }
281
- }
@@ -5,11 +5,13 @@ import { ValidationError, deleteWorkspace, getOrchestrator, getWorkspace, patchW
5
5
  import { applyCommandToRecipe } from "../recipe-runner";
6
6
  import { claimMetadataPatch } from "../workspace-claim";
7
7
  import { clearActiveMemories, injectAlwaysReloadMemories } from "../memory-service";
8
- import { createCommand, getCommand, listCommands, updateCommand } from "../commands-db";
8
+ import { getCommand, listCommands, updateCommand } from "../commands-db";
9
9
  import { emitOrchestratorStatus } from "../sse";
10
10
  import { getCompactionWatch } from "../compaction-watch";
11
11
  import { isRecord } from "agent-relay-sdk";
12
- import { isRequestAuthorizedFor } from "../security";
12
+ import { authContextFromRequest } from "../services/auth-context";
13
+ import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
+ import { ServiceAuthError } from "../services/errors";
13
15
  import { notifyBranchLanded } from "../branch-landed";
14
16
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
15
17
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
@@ -102,30 +104,16 @@ function normalizeCommandInput(body: unknown): CreateCommandInput {
102
104
  return { type, source, target, params, correlationId, ttlMs };
103
105
  }
104
106
 
105
- function commandAuthorizationResource(input: Pick<CreateCommandInput, "target" | "params">): Parameters<typeof isRequestAuthorizedFor>[1]["resource"] {
106
- const params = isRecord(input.params) ? input.params : {};
107
- return {
108
- target: input.target,
109
- agentId: cleanString(params.agentId, "params.agentId", { max: 240 }) ?? input.target,
110
- policyName: cleanString(params.policyName, "params.policyName", { max: 120 }),
111
- orchestratorId: cleanString(params.orchestratorId, "params.orchestratorId", { max: 120 }),
112
- cwd: cleanString(params.cwd, "params.cwd", { max: 500 }),
113
- spawnRequestId: cleanString(params.spawnRequestId, "params.spawnRequestId", { max: 160 }),
114
- };
115
- }
116
-
117
107
  export const postCommand: Handler = async (req) => {
118
108
  const parsed = await parseBody<unknown>(req);
119
109
  if (!parsed.ok) return error(parsed.error, parsed.status);
120
110
  try {
121
111
  const input = normalizeCommandInput(parsed.body);
122
- const denied = authorizeRoute(req, { scope: "command:write", resource: commandAuthorizationResource(input) });
123
- if (denied) return denied;
124
- const command = createCommand(input);
125
- emitCommand(command);
112
+ const command = dispatchCommand(input, authContextFromRequest(req));
126
113
  return json(command, 201);
127
114
  } catch (e) {
128
115
  if (e instanceof ValidationError) return error(e.message, 400);
116
+ if (e instanceof ServiceAuthError) return error("forbidden", 403);
129
117
  throw e;
130
118
  }
131
119
  };
@@ -161,7 +149,7 @@ export const patchCommand: Handler = async (req, params) => {
161
149
  try {
162
150
  const current = getCommand(params.id!);
163
151
  if (!current) return error("command not found", 404);
164
- const denied = authorizeRoute(req, { scope: "command:write", resource: commandAuthorizationResource(current) });
152
+ const denied = authorizeRoute(req, { scope: "command:write", resource: dispatchCommandAuthorizationResource(current) });
165
153
  if (denied) return denied;
166
154
  const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
167
155
  const result = cleanParams(parsed.body.result, "result");
@@ -1,13 +1,14 @@
1
1
  // Auto-split from routes.ts (#299). Domain: messages.
2
2
  import { MAX_BODY_BYTES } from "../config";
3
- import { ValidationError, applyMessageDeliveryAction, claimMessage, deleteMessage, getAgent, getLatestMessageId, getMessage, getMessageDeliveryStatus, getThread, listAgents, listQueuedMessages, listRecentMessages, markRead, pollMessages, recordMessageDeliveryAttempt, renewMessageClaim, sendMessage, sendMessageWithResult, setMessageReaction } from "../db";
4
- import { auditEvent, authorizeRoute, cleanAttachmentRefs, dispatchTaskCallbacks, emitCommand, error, json, memoryContext, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, reactionEmoji, sendReactionNotificationToAuthor, withPayloadAttachments, type Handler } from "./_shared";
3
+ import { ValidationError, applyMessageDeliveryAction, claimMessage, deleteMessage, getAgent, getLatestMessageId, getMessage, getMessageDeliveryStatus, getThread, listQueuedMessages, listRecentMessages, markRead, pollMessages, recordMessageDeliveryAttempt, renewMessageClaim, sendMessage, sendMessageWithResult, setMessageReaction } from "../db";
4
+ import { auditEvent, cleanAttachmentRefs, dispatchTaskCallbacks, emitCommand, error, json, memoryContext, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, reactionEmoji, sendReactionNotificationToAuthor, withPayloadAttachments, type Handler } from "./_shared";
5
5
  import { cleanMeta, cleanPositiveId, cleanString, optionalEnum } from "../validation";
6
6
  import { emitMessageClaimed, emitMessageDeleted, emitMessageDeliveryUpdated, emitMessageQueued, emitMessageReactionUpdated, emitNewMessage, emitTaskChanged } from "../sse";
7
- import { errMessage, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
8
- import { getLifecycleManager } from "../lifecycle-manager";
7
+ import { errMessage, isMechanicalMessageKind, isRecord } from "agent-relay-sdk";
9
8
  import { injectMemoryForMessageDelivery } from "../memory-service";
10
- import { planSend } from "../agent-ref";
9
+ import { authContextFromRequest } from "../services/auth-context";
10
+ import { sendMessageService } from "../services/send-message";
11
+ import { AmbiguousTargetError, ServiceAuthError } from "../services/errors";
11
12
  import { type AgentSessionGuard, type Message, type SendMessageInput } from "../types";
12
13
 
13
14
  const VALID_DELIVERY_STATUSES = ["pending", "delivered", "queued", "failed", "dead"] as const;
@@ -71,27 +72,6 @@ function isDirectTarget(to: string): boolean {
71
72
  return to !== "broadcast" && !FANOUT_PREFIXES.some((p) => to.startsWith(p));
72
73
  }
73
74
 
74
- function applyReplyRouting(input: SendMessageInput): void {
75
- if (input.to || !input.replyTo) return;
76
- const parent = getMessage(input.replyTo);
77
- if (!parent) return;
78
- input.to = parent.from;
79
- if (!input.channel && parent.channel) input.channel = parent.channel;
80
- const parentPayload = parent.payload ?? {};
81
- if (parentPayload.schema === "agent-relay.channel.v1" || parentPayload.conversation) {
82
- const replyContext: Record<string, unknown> = {};
83
- if (parent.channel) replyContext.channelId = parent.channel;
84
- if (parentPayload.conversation && typeof parentPayload.conversation === "object") {
85
- replyContext.conversationId = (parentPayload.conversation as Record<string, unknown>).id;
86
- }
87
- if (parentPayload.event && typeof parentPayload.event === "object") {
88
- replyContext.parentEventId = (parentPayload.event as Record<string, unknown>).id;
89
- }
90
- if (parentPayload.source) replyContext.source = parentPayload.source;
91
- input.payload = { ...input.payload, replyContext };
92
- }
93
- }
94
-
95
75
  const VALID_MSG_KINDS = ["chat", "channel.event", "task", "pair", "control", "system", "session"];
96
76
 
97
77
  export const getQueuedMessagesRoute: Handler = (req) => {
@@ -173,83 +153,40 @@ export const postMessage: Handler = async (req) => {
173
153
  if (!input.idempotencyKey) {
174
154
  input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
175
155
  }
176
- applyReplyRouting(input);
177
- if (!input.to) return error("to is required (or provide replyTo to auto-route)");
178
- // Mechanical lifecycle/observability posts (system/control/session) addressed to a
179
- // reserved sink ("user"/"system") are the relay's own lane, not agent-directed
180
- // messaging. A managed token's recipient constraints (targets/policies/agents) gate
181
- // which *agents* it may message they must NOT gate a session-mirror capture to the
182
- // reserved sink, or constrained tokens (telegram policy, codex steward) 403 the
183
- // runner's outbox retries 12× and poisons the record → the dashboard silently loses
184
- // the turn (#284, same outbox-poison failure mode as #184). The message:send scope
185
- // and any channel constraint still apply; we only drop the target/agentId predicate.
186
- const reservedSinkPost = isMechanicalMessageKind(input.kind) && isReservedAgentId(input.to);
187
- const denied = authorizeRoute(req, {
188
- scope: "message:send",
189
- resource: reservedSinkPost
190
- ? { channel: input.channel }
191
- : { target: input.to, channel: input.channel, agentId: input.from },
192
- });
193
- if (denied) return denied;
194
- // Resolve the target through the shared planner — the SAME matcher the MCP send tool
195
- // uses (planSend → matchAgents) — so a bare label / name / id-segment that matches an
196
- // existing agent is rewritten to its canonical id. Poll-time matching is exact, so
197
- // without this a bare label is stored verbatim and reaches no one (#234). An ambiguous
198
- // direct ref can't be delivered, so reject it up front. Unknown targets are left
199
- // untouched on purpose: sending to an id that isn't registered yet is a supported
200
- // "store-ahead" pattern (delivered once that agent registers and polls). Fan-out and
201
- // reserved/policy targets carry through unchanged (and may legitimately match zero now).
202
- //
203
- // "session" = observed assistant turn (Phase 1 live-session lane). It is captured
204
- // from the provider transcript and stored for the dashboard chat; it must persist
205
- // regardless of target liveness and never be re-delivered into a session.
206
- if (!isMechanicalMessageKind(input.kind)) {
207
- // excludeId: a bare ref must never resolve back to its own author — that self-loop
208
- // silently swallows the message (the reddit-briefing → telegram bridge break, #290).
209
- const plan = planSend(input.to, listAgents(), { excludeId: input.from });
210
- if (plan.kind === "ambiguous") return error(plan.message, 409);
211
- if (plan.kind !== "not_found") input.to = plan.to;
212
- // Long-standing guard: refuse a direct send to a known-offline agent (now also
213
- // catches a ref that resolved to an offline agent by label/segment).
214
- const target = getAgent(input.to);
215
- if (target && target.status === "offline") {
216
- return error(`agent "${input.to}" is offline`, 422);
217
- }
218
- }
219
- const result = sendMessageWithResult(input);
220
- if (result.created) {
221
- const autoMemoryTarget = automaticMemoryTarget(result.message);
156
+ // The shared send service owns reply-routing, target resolution + the converged store-ahead
157
+ // policy, authorization (incl. the reserved-sink mechanical exemption), persistence, and the
158
+ // emit + on-demand-policy-wake — identical across HTTP / MCP (epic #342, src/services/send-message.ts).
159
+ const { message, created } = sendMessageService(input, authContextFromRequest(req));
160
+ if (created) {
161
+ // Transport-edge enrichment (NOT part of the cross-transport send core): automatic memory
162
+ // injection needs request-derived context; the "message sent" audit is the HTTP domain row.
163
+ const autoMemoryTarget = automaticMemoryTarget(message);
222
164
  if (autoMemoryTarget) {
223
165
  try {
224
- const memoryInjection = await injectMemoryForMessageDelivery(result.message, autoMemoryTarget, memoryContext(req));
166
+ const memoryInjection = await injectMemoryForMessageDelivery(message, autoMemoryTarget, memoryContext(req));
225
167
  if (memoryInjection) emitCommand(memoryInjection.command);
226
168
  } catch (e) {
227
169
  console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`);
228
170
  }
229
171
  }
230
- if (result.message.deliveryStatus === "queued") {
231
- emitMessageQueued(result.message);
232
- // Wake an on-demand managed policy that has no live agent attached yet.
233
- if (result.message.to?.startsWith("policy:")) {
234
- getLifecycleManager().onMessageForPolicy(result.message.to.slice("policy:".length));
235
- }
236
- } else emitNewMessage(result.message);
237
- const isWork = result.message.kind === "task";
172
+ const isWork = message.kind === "task";
238
173
  auditEvent({
239
- clientId: "server-message-" + result.message.id,
174
+ clientId: "server-message-" + message.id,
240
175
  kind: isWork ? "task" : "message",
241
176
  title: isWork ? "Task message sent" : "Message sent",
242
- body: result.message.subject || result.message.body,
243
- meta: `${result.message.from} -> ${result.message.to}`,
177
+ body: message.subject || message.body,
178
+ meta: `${message.from} -> ${message.to}`,
244
179
  icon: isWork ? "ti-hand-grab" : "ti-send",
245
180
  view: "messages",
246
- messageId: result.message.id,
247
- agentId: result.message.from,
181
+ messageId: message.id,
182
+ agentId: message.from,
248
183
  });
249
184
  }
250
- return json(result.message, result.created ? 201 : 200);
185
+ return json(message, created ? 201 : 200);
251
186
  } catch (e) {
252
187
  if (e instanceof ValidationError) return error(e.message, 400);
188
+ if (e instanceof AmbiguousTargetError) return error(e.message, 409);
189
+ if (e instanceof ServiceAuthError) return error("forbidden", 403);
253
190
  throw e;
254
191
  }
255
192
  };
package/src/security.ts CHANGED
@@ -185,6 +185,11 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
185
185
  if (pathname.startsWith("/api/channels") || pathname.startsWith("/api/channel-bindings")) return method === "GET" ? "channel:read" : "channel:write";
186
186
  if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integration:read" : "integration:write";
187
187
  if (pathname.startsWith("/api/automations") || pathname.startsWith("/api/automation-runs")) return method === "GET" ? "automations:read" : "automations:write";
188
+ // Spawning a worker requires the spawn capability, not generic agent-write — the in-service
189
+ // gate (src/services/spawn-agent.ts) enforces the full quota/no-grandchild rules; this coarse
190
+ // route gate just matches it so an `agent:write`-but-not-`command:spawn` token can't reach it
191
+ // (the MCP spawn tool already required command:spawn — epic #342, #349).
192
+ if (pathname === "/api/agents/spawn") return "command:spawn";
188
193
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
189
194
  if (pathname.startsWith("/api/activity")) return method === "GET" ? "activity:read" : "activity:write";
190
195
  if (pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
@@ -264,6 +269,8 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
264
269
  return "memory:write";
265
270
  }
266
271
  if (pathname === "/api/integrations" || pathname.startsWith("/api/integrations/")) return method === "GET" ? "integration:read" : "integration:write";
272
+ // Spawn requires the spawn capability (see requiredScopeFor above) — matches the in-service gate.
273
+ if (pathname === "/api/agents/spawn") return "command:spawn";
267
274
  if (pathname.startsWith("/api/agents")) return method === "GET" ? "agent:read" : "agent:write";
268
275
  if (pathname.startsWith("/api/messages") || pathname.startsWith("/api/inbox")) return method === "GET" ? "message:read" : "message:send";
269
276
  if (pathname.startsWith("/api/agent-profiles")) return method === "GET" ? "agent:read" : "agent:write";
@@ -0,0 +1,109 @@
1
+ // Transport convergence (epic #342) — unified auth shape.
2
+ //
3
+ // Every transport (HTTP routes, WS bus, MCP, internal projections) builds ONE
4
+ // `AuthContext` and hands it to the domain services in `src/services/`. Services
5
+ // never see a transport-specific Request / WebSocket / frame — they read identity,
6
+ // scopes, and signed constraints from this single shape. This kills the
7
+ // "I forgot this path also needs the constraint" class of bug (e.g. the v0.36.2
8
+ // `spawned_by`-NULL drift: lineage lived in the HTTP path's token read but not the
9
+ // bus path's). Lineage now resolves uniformly from `ctx.constraints`.
10
+ import type { AgentCard, ComponentToken, TokenConstraints } from "../types";
11
+ import { getComponentAuth, isAuthorized } from "../security";
12
+ import { resolveCallerAgentId } from "../agent-ref";
13
+ import { listAgents } from "../db";
14
+
15
+ export interface AuthContext {
16
+ /** The authenticated principal. `system` is an internal, fully-trusted caller
17
+ * (lifecycle tick, orchestrator-report projection) with no token. */
18
+ actor: { id: string; kind: "component" | "agent" | "system" | "user" | "integration" };
19
+ /** Capability scopes the principal holds (e.g. "agent:write", "command:spawn").
20
+ * Empty for `system` (internal callers bypass scope checks). Consumed by the
21
+ * shutdown/spawn slices; registration only needs `constraints`. */
22
+ scopes: string[];
23
+ /** Signed token constraints — the AUTHORITATIVE source for spawn lineage
24
+ * (`resolveSpawnLineage(ctx.constraints)`), cwd prefixes, spawn-request ids, etc.
25
+ * A client can never forge these; they are baked into the token at issue time. */
26
+ constraints?: TokenConstraints;
27
+ /** The resolved CALLER AGENT ID behind this token — the agent the principal *is*,
28
+ * NOT `actor.id` (the token `sub`). Resolved from the signed constraints via
29
+ * `resolveCallerAgentId` (single `agents` id, else a single spawnRequestId/policy matched
30
+ * to a live agent — the #243 identity family). undefined for admin/server tokens (full
31
+ * reach) and multi-agent tokens. THE basis for the parent→child shutdown gate and the
32
+ * no-grandchild spawn gate (#221) — consumed by the shutdown/spawn/send services. */
33
+ callerAgentId?: string;
34
+ /** The raw component token when the principal authenticated with one. Escape
35
+ * hatch for code still calling `isComponentAuthorizedFor` directly during the
36
+ * transport-convergence migration; new service code should prefer `scopes`/`constraints`. */
37
+ component?: ComponentToken;
38
+ }
39
+
40
+ function agentsSnapshot(): AgentCard[] {
41
+ return listAgents();
42
+ }
43
+
44
+ function fromComponent(component: ComponentToken | null | undefined): AuthContext {
45
+ if (!component) return { actor: { id: "anonymous", kind: "user" }, scopes: [] };
46
+ return {
47
+ actor: { id: component.sub, kind: "component" },
48
+ scopes: component.scope ?? [],
49
+ constraints: component.constraints,
50
+ // Lazy: resolveCallerAgentId only scans live agents when a single spawnRequestId/policy
51
+ // must be matched; identity-bearing (`agents`) and admin tokens pay no db cost here.
52
+ callerAgentId: resolveCallerAgentId(component.constraints, agentsSnapshot),
53
+ component,
54
+ };
55
+ }
56
+
57
+ /** True if the request carries any credential (so the in-handler gate should scope it, rather
58
+ * than trust the pipeline). Mirrors the routes layer's `hasPresentedCredential`. */
59
+ function hasPresentedCredential(req: Request): boolean {
60
+ return Boolean(
61
+ req.headers.get("authorization") ||
62
+ req.headers.get("x-agent-relay-token") ||
63
+ new URL(req.url).searchParams.get("token"),
64
+ );
65
+ }
66
+
67
+ /** HTTP transport adapter: build an AuthContext from a request's auth.
68
+ * - A component token maps to its identity / scopes / signed constraints (the scoped path).
69
+ * - Everything else — the admin token (dashboard), an allow-unauth dev request, OR a
70
+ * credential-less request that the auth PIPELINE already authenticated before dispatch —
71
+ * maps to a full-reach system principal (`*` scope), mirroring authContextFromMcp's `server`
72
+ * branch AND the routes-layer `authorizeRoute` (which no-ops when no credential is presented).
73
+ * This keeps scope-gating services (spawn/shutdown) from re-rejecting a caller the request-level
74
+ * authz already allowed. In production a truly unauthenticated request is stopped at the pipeline,
75
+ * so the credential-less branch only covers in-process callers that bypass it. */
76
+ export function authContextFromRequest(req: Request): AuthContext {
77
+ const component = getComponentAuth(req);
78
+ if (component) return fromComponent(component);
79
+ if (isAuthorized(req) || !hasPresentedCredential(req)) return { actor: { id: "system", kind: "system" }, scopes: ["*"] };
80
+ return fromComponent(null);
81
+ }
82
+
83
+ /** WS-bus transport adapter: build an AuthContext from a socket's component token. */
84
+ export function authContextFromBus(component: ComponentToken | null | undefined): AuthContext {
85
+ return fromComponent(component);
86
+ }
87
+
88
+ /** Internal-caller adapter: a fully-trusted system principal with no token
89
+ * (lifecycle-manager tick, orchestrator-report projection). No lineage — these
90
+ * callers act on already-registered agents and never set authoritative parentage. */
91
+ export function authContextFromSystem(): AuthContext {
92
+ return { actor: { id: "system", kind: "system" }, scopes: [] };
93
+ }
94
+
95
+ /** MCP transport adapter. Typed structurally (not against mcp.ts's `McpAuthContext`) so
96
+ * services stay below the transport layer. `server` = an internal/admin caller with no token
97
+ * (full reach, `*` scope); `integration` = an integration token (no caller-agent identity);
98
+ * `component` reuses the same component path as HTTP/bus, so the caller-agent + lineage
99
+ * resolution is identical across every transport. */
100
+ export function authContextFromMcp(auth: {
101
+ kind: "component" | "integration" | "server";
102
+ actor: string;
103
+ scopes?: string[];
104
+ component?: ComponentToken;
105
+ }): AuthContext {
106
+ if (auth.kind === "component") return fromComponent(auth.component);
107
+ if (auth.kind === "server") return { actor: { id: auth.actor, kind: "system" }, scopes: auth.scopes ?? ["*"] };
108
+ return { actor: { id: auth.actor, kind: "integration" }, scopes: auth.scopes ?? [] };
109
+ }