agent-relay-server 0.36.2 → 0.38.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 (95) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{activity-C3mkM6AU.js → activity-ClpDglG8.js} +2 -2
  4. package/public/assets/{activity-C3mkM6AU.js.map → activity-ClpDglG8.js.map} +1 -1
  5. package/public/assets/{agent-profiles-DS4_jLPT.js → agent-profiles-kb5H23CF.js} +2 -2
  6. package/public/assets/{agent-profiles-DS4_jLPT.js.map → agent-profiles-kb5H23CF.js.map} +1 -1
  7. package/public/assets/{agents-CAhQO7JH.js → agents-CHmEJvqV.js} +2 -2
  8. package/public/assets/{agents-CAhQO7JH.js.map → agents-CHmEJvqV.js.map} +1 -1
  9. package/public/assets/{analytics-BwihhhNn.js → analytics-2kTjXIj1.js} +3 -3
  10. package/public/assets/{analytics-BwihhhNn.js.map → analytics-2kTjXIj1.js.map} +1 -1
  11. package/public/assets/{automation-BLXToUiU.js → automation-B5U_g-1P.js} +2 -2
  12. package/public/assets/{automation-BLXToUiU.js.map → automation-B5U_g-1P.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-D8-T2c1K.js → branch-state-badge-B1K7aIzF.js} +2 -2
  14. package/public/assets/{branch-state-badge-D8-T2c1K.js.map → branch-state-badge-B1K7aIzF.js.map} +1 -1
  15. package/public/assets/{channels-ppN8k4hu.js → channels-DyPw9JsY.js} +2 -2
  16. package/public/assets/{channels-ppN8k4hu.js.map → channels-DyPw9JsY.js.map} +1 -1
  17. package/public/assets/chat-zPXWB-03.js +2 -0
  18. package/public/assets/chat-zPXWB-03.js.map +1 -0
  19. package/public/assets/{connectors-CL9BALhF.js → connectors-k7JYCrrl.js} +2 -2
  20. package/public/assets/{connectors-CL9BALhF.js.map → connectors-k7JYCrrl.js.map} +1 -1
  21. package/public/assets/display-ConJ9cJB.js.map +1 -1
  22. package/public/assets/{formatted-body-impl-BHH0wzY7.js → formatted-body-impl-tmf8IBfr.js} +2 -2
  23. package/public/assets/{formatted-body-impl-BHH0wzY7.js.map → formatted-body-impl-tmf8IBfr.js.map} +1 -1
  24. package/public/assets/index-B1QUkb_O.js +21 -0
  25. package/public/assets/index-B1QUkb_O.js.map +1 -0
  26. package/public/assets/index-Bins8N_5.css +2 -0
  27. package/public/assets/{integrations-DX55ARy0.js → integrations-BEkyjBAs.js} +2 -2
  28. package/public/assets/{integrations-DX55ARy0.js.map → integrations-BEkyjBAs.js.map} +1 -1
  29. package/public/assets/{maintenance-9n_rJCHT.js → maintenance-Tn23oWBF.js} +2 -2
  30. package/public/assets/{maintenance-9n_rJCHT.js.map → maintenance-Tn23oWBF.js.map} +1 -1
  31. package/public/assets/{managed-agents-Rp2-xpBx.js → managed-agents-CasacvJX.js} +2 -2
  32. package/public/assets/{managed-agents-Rp2-xpBx.js.map → managed-agents-CasacvJX.js.map} +1 -1
  33. package/public/assets/{markdown-preview-impl-YfJsGh6I.js → markdown-preview-impl-D4UIjB3I.js} +2 -2
  34. package/public/assets/{markdown-preview-impl-YfJsGh6I.js.map → markdown-preview-impl-D4UIjB3I.js.map} +1 -1
  35. package/public/assets/{memory-BQONtGQS.js → memory-SVCob0fo.js} +2 -2
  36. package/public/assets/{memory-BQONtGQS.js.map → memory-SVCob0fo.js.map} +1 -1
  37. package/public/assets/{messages-DGqpkH72.js → messages-CHK24Uxx.js} +2 -2
  38. package/public/assets/{messages-DGqpkH72.js.map → messages-CHK24Uxx.js.map} +1 -1
  39. package/public/assets/{orchestrators-b8k9QoGv.js → orchestrators-CQcJb6VE.js} +2 -2
  40. package/public/assets/{orchestrators-b8k9QoGv.js.map → orchestrators-CQcJb6VE.js.map} +1 -1
  41. package/public/assets/{overview-DSU_CggA.js → overview-DbyX7k-7.js} +2 -2
  42. package/public/assets/{overview-DSU_CggA.js.map → overview-DbyX7k-7.js.map} +1 -1
  43. package/public/assets/{pairs-DGocNC1U.js → pairs-CaL0_ZfW.js} +2 -2
  44. package/public/assets/{pairs-DGocNC1U.js.map → pairs-CaL0_ZfW.js.map} +1 -1
  45. package/public/assets/{security-BSh0QxOl.js → security-BogsfkbT.js} +2 -2
  46. package/public/assets/{security-BSh0QxOl.js.map → security-BogsfkbT.js.map} +1 -1
  47. package/public/assets/{settings-C03CAJgO.js → settings-BOsnUh5f.js} +2 -2
  48. package/public/assets/{settings-C03CAJgO.js.map → settings-BOsnUh5f.js.map} +1 -1
  49. package/public/assets/{store-DKVWC6Uh.js → store-Bo72e9My.js} +2 -2
  50. package/public/assets/{store-DKVWC6Uh.js.map → store-Bo72e9My.js.map} +1 -1
  51. package/public/assets/{tasks-rKbuUPOk.js → tasks-CCxQovOv.js} +2 -2
  52. package/public/assets/{tasks-rKbuUPOk.js.map → tasks-CCxQovOv.js.map} +1 -1
  53. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js → terminal-viewer-impl-BDikdsxs.js} +2 -2
  54. package/public/assets/{terminal-viewer-impl-CA8u4jh3.js.map → terminal-viewer-impl-BDikdsxs.js.map} +1 -1
  55. package/public/assets/{work-queue-DOsA9s4M.js → work-queue-fM-tu0iP.js} +2 -2
  56. package/public/assets/{work-queue-DOsA9s4M.js.map → work-queue-fM-tu0iP.js.map} +1 -1
  57. package/public/assets/{workspaces-CoC2nflZ.js → workspaces-Df0xJuIo.js} +2 -2
  58. package/public/assets/{workspaces-CoC2nflZ.js.map → workspaces-Df0xJuIo.js.map} +1 -1
  59. package/public/index.html +3 -3
  60. package/runner/src/adapter.ts +7 -0
  61. package/scripts/orchestrator-spawn-smoke.ts +65 -33
  62. package/src/agent-ref.ts +28 -1
  63. package/src/automations.ts +17 -2
  64. package/src/bus.ts +52 -41
  65. package/src/cli/index.ts +1 -1
  66. package/src/cli/workspace.ts +36 -3
  67. package/src/compaction-watch.ts +7 -0
  68. package/src/config-store.ts +46 -0
  69. package/src/index.ts +23 -6
  70. package/src/lifecycle-manager.ts +33 -71
  71. package/src/maintenance.ts +6 -1
  72. package/src/mcp.ts +106 -309
  73. package/src/routes/agent-sessions.ts +38 -3
  74. package/src/routes/agents-spawn.ts +43 -174
  75. package/src/routes/commands.ts +7 -19
  76. package/src/routes/messages.ts +24 -87
  77. package/src/routes/workspaces.ts +4 -1
  78. package/src/security.ts +7 -0
  79. package/src/services/auth-context.ts +109 -0
  80. package/src/services/dispatch-command.ts +60 -0
  81. package/src/services/errors.ts +26 -0
  82. package/src/services/managed-running.ts +130 -0
  83. package/src/services/parity-harness.ts +135 -0
  84. package/src/services/register-agent.ts +74 -0
  85. package/src/services/send-message.ts +177 -0
  86. package/src/services/shutdown-agent.ts +234 -0
  87. package/src/services/spawn-agent.ts +284 -0
  88. package/src/workspace-actions.ts +5 -1
  89. package/src/workspace-merge.ts +102 -3
  90. package/src/workspace-phase.ts +15 -1
  91. package/public/assets/chat-8iIPyww9.js +0 -2
  92. package/public/assets/chat-8iIPyww9.js.map +0 -1
  93. package/public/assets/index-3pO43nJo.css +0 -2
  94. package/public/assets/index-CaauKXl9.js +0 -21
  95. package/public/assets/index-CaauKXl9.js.map +0 -1
@@ -0,0 +1,234 @@
1
+ // Transport convergence (epic #342, slice #347) — the agent-shutdown service.
2
+ //
3
+ // ONE home for "shut down an agent": the #221 parent→child authorization gate, the
4
+ // control-orchestrator resolution, and the canonical `agent.shutdown` command payload
5
+ // + side-effects. Previously the gate existed ONLY on the MCP path (mcp.ts
6
+ // `relayShutdownAgent`), while the HTTP route (`postAgentAction`) and the bus command
7
+ // frame (`handleCommandFrame`) created `agent.shutdown` commands with NO parent→child
8
+ // gate — so a scoped `agent:write`/`command:write` caller could shut down agents the
9
+ // MCP gate would refuse. This converges all three UP to the strict #221 model: an
10
+ // agent caller may shut down only its OWN live spawned children, addressed by agentId;
11
+ // admin/server/system tokens (no resolved caller-agent identity) keep full reach.
12
+ //
13
+ // The gate is IDENTITY-based (`ctx.callerAgentId`), not scope-based: the coarse command
14
+ // scope stays enforced at each transport's existing layer (HTTP `agent:write`, bus
15
+ // `command:write`, MCP `command:shutdown`); this service adds the fine parent→child gate
16
+ // uniformly. Search-verified (#347): no first-party non-admin caller hits these surfaces
17
+ // today, so the tightening blocks no legitimate flow — it closes the HTTP/bus bypass.
18
+ //
19
+ // SCOPE BOUNDARY (inherited by #348's transport-thinness ratchet): this service backs
20
+ // the THREE per-agent shutdown surfaces — HTTP `/api/agents/:id/actions` (action
21
+ // "shutdown"), the bus `agent.shutdown` command frame, and MCP `relay_shutdown_agent`.
22
+ // It deliberately does NOT back the OPERATOR surfaces that emit `agent.shutdown` as a
23
+ // side effect of policy/host management (`routes/spawn-policy.ts`, `routes/orchestrator.ts`)
24
+ // nor the internal lifecycle flows (`lifecycle-manager.stopAgent`, the maintenance
25
+ // reaper, automations, recipe-runner): those have no per-caller-agent identity and keep
26
+ // their own admin/`command:write` auth. Do not fold them in — gating an operator on the
27
+ // agent's `spawnedBy` parentage is a category error.
28
+ import {
29
+ ValidationError,
30
+ createActivityEvent,
31
+ getAgent,
32
+ getOrchestrator,
33
+ mergeAgentMeta,
34
+ } from "../db";
35
+ import { createCommand } from "../commands-db";
36
+ import { emitCommandEvent } from "../command-events";
37
+ import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
38
+ import { emitActivityEvent, emitAgentStatus } from "../sse";
39
+ import { stringValue } from "agent-relay-sdk";
40
+ import type { AuthContext } from "./auth-context";
41
+ import type { AgentCard, Command } from "../types";
42
+
43
+ /** The caller is not authorized to shut down the target. Mapped by transports to
44
+ * HTTP 403 / MCP `McpAuthError` (-32001) / bus `rejected`. */
45
+ export class ShutdownAuthError extends Error {}
46
+ /** No agent/orchestrator matched the shutdown target. Mapped to HTTP 404 / MCP
47
+ * `McpNotFoundError` (-32004) / bus `failed`. */
48
+ export class ShutdownTargetError extends Error {}
49
+
50
+ type OnlineOrchestrator = NonNullable<ReturnType<typeof getOrchestrator>>;
51
+
52
+ export interface ShutdownAgentInput {
53
+ agentId?: string;
54
+ policyName?: string;
55
+ spawnRequestId?: string;
56
+ tmuxSession?: string;
57
+ orchestratorId?: string;
58
+ graceful?: boolean;
59
+ timeoutMs?: number;
60
+ reason?: string;
61
+ /** Transport that originated the shutdown — recorded on the command for audit. */
62
+ requestedVia: "http" | "bus" | "mcp";
63
+ /** Attribution label for the audit trail (e.g. "dashboard", the MCP actor id). */
64
+ requestedBy?: string;
65
+ /** Transport-specific audit attribution merged into the activity-event metadata
66
+ * (e.g. the HTTP path's `dashboardAttribution` surface + `authAuditMetadata` token jti),
67
+ * which the service can't derive from the AuthContext alone. */
68
+ auditMetadata?: Record<string, unknown>;
69
+ }
70
+
71
+ export interface ShutdownAgentResult {
72
+ command: Command;
73
+ orchestratorId: string;
74
+ agent: AgentCard | null;
75
+ }
76
+
77
+ function str(v: unknown): string | undefined {
78
+ return typeof v === "string" && v.length > 0 ? v : undefined;
79
+ }
80
+
81
+ /** Build a ShutdownAgentInput from a WS-bus `agent.shutdown` command frame's target +
82
+ * params. Lives here (not in bus.ts) so the bus transport stays a thin dispatcher and the
83
+ * shutdown-input shape has one home. */
84
+ export function shutdownInputFromBusFrame(
85
+ target: string,
86
+ params: Record<string, unknown>,
87
+ requestedByFallback?: string,
88
+ ): ShutdownAgentInput {
89
+ return {
90
+ agentId: stringValue(params.agentId) ?? target,
91
+ policyName: stringValue(params.policyName),
92
+ spawnRequestId: stringValue(params.spawnRequestId),
93
+ tmuxSession: stringValue(params.tmuxSession),
94
+ orchestratorId: stringValue(params.orchestratorId),
95
+ graceful: typeof params.graceful === "boolean" ? params.graceful : undefined,
96
+ timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
97
+ reason: stringValue(params.reason),
98
+ requestedVia: "bus",
99
+ requestedBy: stringValue(params.requestedBy) ?? requestedByFallback,
100
+ };
101
+ }
102
+
103
+ /** The #221 parent→child gate — the single home for shutdown authorization across all
104
+ * three transports. Throws `ShutdownAuthError` on denial; returns for full-reach callers. */
105
+ export function authorizeShutdown(ctx: AuthContext, input: ShutdownAgentInput): void {
106
+ const callerId = ctx.callerAgentId;
107
+ // Full reach: admin/server/system (no resolved caller identity) and multi-agent tokens.
108
+ if (!callerId) return;
109
+ // A scoped agent caller may target only its OWN live spawned children, addressed by
110
+ // agentId. Broad targeting (policy/spawnRequestId/tmux) and cross-agent kills stay
111
+ // full-reach-only — otherwise spawn permission silently becomes kill-anyone permission.
112
+ if (!input.agentId || input.policyName || input.spawnRequestId || input.tmuxSession) {
113
+ throw new ShutdownAuthError("agents may only shut down their own spawned children, addressed by agentId");
114
+ }
115
+ const target = getAgent(input.agentId);
116
+ if (!target || target.spawnedBy !== callerId) {
117
+ throw new ShutdownAuthError(`agent ${input.agentId} is not one of your spawned children`);
118
+ }
119
+ }
120
+
121
+ /** Resolve the online control orchestrator for the target — the canonical resolver,
122
+ * lifted from mcp.ts `selectControlOrchestrator` (its sole caller) and widened to also
123
+ * consider the agent's `sessionName` (the HTTP path's candidate), so HTTP and MCP
124
+ * resolve the SAME orchestrator for the same agent. Requires an online orchestrator —
125
+ * a managed agent can't be shut down without one (the command would target a dead
126
+ * agent). The previous HTTP path silently emitted an orphaned command when none was
127
+ * online; this converges to a clear error. */
128
+ function resolveControlOrchestrator(input: ShutdownAgentInput, agent: AgentCard | null): OnlineOrchestrator {
129
+ if (input.orchestratorId) {
130
+ const orchestrator = getOrchestrator(input.orchestratorId);
131
+ if (!orchestrator) throw new ShutdownTargetError(`orchestrator ${input.orchestratorId} not found`);
132
+ if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
133
+ return orchestrator;
134
+ }
135
+ const candidates: OnlineOrchestrator[] = [
136
+ ...listManagedOrchestratorsForAgent({
137
+ agentId: agent?.id ?? input.agentId,
138
+ sessionName: str(agent?.meta?.sessionName),
139
+ tmuxSession: input.tmuxSession ?? str(agent?.meta?.tmuxSession),
140
+ policyName: input.policyName ?? str(agent?.meta?.policyName),
141
+ spawnRequestId: input.spawnRequestId ?? str(agent?.meta?.spawnRequestId),
142
+ }),
143
+ ...(agent?.machine
144
+ ? [getOrchestrator(agent.machine)].filter((o): o is OnlineOrchestrator => Boolean(o))
145
+ : []),
146
+ ];
147
+ const online = candidates.find((o) => o.status === "online");
148
+ if (!online) {
149
+ if (candidates.length === 0) throw new ShutdownTargetError("no orchestrator found for agent control target");
150
+ throw new ValidationError("orchestrator is offline");
151
+ }
152
+ return online;
153
+ }
154
+
155
+ /** Shut down an agent through its control orchestrator. Authorizes (#221), resolves the
156
+ * orchestrator + target, builds the canonical command, emits it, marks the agent
157
+ * shutting-down (uniform across transports — MCP previously skipped this), and writes the
158
+ * audit row. The single fat-service entry point the three thin transports call. */
159
+ export function shutdownAgent(input: ShutdownAgentInput, ctx: AuthContext): ShutdownAgentResult {
160
+ authorizeShutdown(ctx, input);
161
+
162
+ const agent = input.agentId ? getAgent(input.agentId) : null;
163
+ const orchestrator = resolveControlOrchestrator(input, agent);
164
+ const targetAgentId = input.agentId ?? agent?.id;
165
+ const policyName = input.policyName ?? str(agent?.meta?.policyName);
166
+ const spawnRequestId = input.spawnRequestId ?? str(agent?.meta?.spawnRequestId);
167
+ const tmuxSession = input.tmuxSession ?? str(agent?.meta?.tmuxSession);
168
+ const sessionName = str(agent?.meta?.sessionName);
169
+
170
+ const command = createCommand({
171
+ type: "agent.shutdown",
172
+ source: "system",
173
+ target: orchestrator.agentId,
174
+ correlationId: spawnRequestId,
175
+ params: {
176
+ action: "shutdown",
177
+ ...(targetAgentId ? { agentId: targetAgentId } : {}),
178
+ ...(policyName ? { policyName } : {}),
179
+ ...(spawnRequestId ? { spawnRequestId } : {}),
180
+ ...(tmuxSession ? { tmuxSession } : {}),
181
+ ...(sessionName ? { sessionName } : {}),
182
+ graceful: input.graceful ?? true,
183
+ timeoutMs: input.timeoutMs ?? 10_000,
184
+ reason: input.reason ?? `${input.requestedVia}-shutdown`,
185
+ orchestratorId: orchestrator.id,
186
+ requestedBy: input.requestedBy ?? "system",
187
+ requestedVia: input.requestedVia,
188
+ requestedAt: Date.now(),
189
+ },
190
+ });
191
+ emitCommandEvent(command, "command.requested");
192
+
193
+ // Lifecycle indicator — uniform across transports. The MCP path previously skipped
194
+ // this, so an MCP-initiated shutdown left the agent with no "shutting down" signal in
195
+ // the dashboard until it actually exited; now every transport sets it. We set ONLY the
196
+ // `lifecycleAction` meta (which drives the dashboard pill) and DON'T flip `ready` — the
197
+ // agent is still alive until the command executes, and a child mid-shutdown must keep
198
+ // occupying its parent's spawn-quota slot (`countLiveSpawnedAgents` counts ready=1)
199
+ // until it genuinely exits. The agent clears `ready` itself on exit / via the reaper.
200
+ if (agent) {
201
+ mergeAgentMeta(agent.id, {
202
+ lifecycleAction: "shutting-down",
203
+ lifecycleActionAt: Date.now(),
204
+ lifecycleCommandId: command.id,
205
+ });
206
+ emitAgentStatus(agent.id);
207
+ }
208
+
209
+ if (targetAgentId) {
210
+ const event = createActivityEvent({
211
+ clientId: `server-agent-${targetAgentId}-action-shutdown-${command.id}`,
212
+ kind: "state",
213
+ title: "Agent shutdown requested",
214
+ body: "shutdown",
215
+ meta: targetAgentId,
216
+ icon: "ti-power",
217
+ view: "agents",
218
+ agentId: targetAgentId,
219
+ metadata: {
220
+ action: "shutdown",
221
+ commandId: command.id,
222
+ orchestratorId: orchestrator.id,
223
+ actor: ctx.actor.id,
224
+ actorKind: ctx.actor.kind,
225
+ requestedVia: input.requestedVia,
226
+ ...(input.auditMetadata ?? {}),
227
+ source: "server",
228
+ },
229
+ });
230
+ emitActivityEvent(event);
231
+ }
232
+
233
+ return { command, orchestratorId: orchestrator.id, agent };
234
+ }
@@ -0,0 +1,284 @@
1
+ // Transport convergence (epic #342, slice #349) — the agent-spawn service.
2
+ //
3
+ // ONE home for "spawn a provider agent through the orchestrator". Previously the
4
+ // security core was inlined in the MCP `relay_spawn_agent` handler ONLY: the
5
+ // `command:spawn` scope, the #221 no-grandchild + maxSpawnedAgents quota gate, the
6
+ // #323 orchestrator-resource gate, authoritative `spawnedBy` lineage stamping, and
7
+ // the #328/#331/#324 caller-context inheritance (host / cwd / approvalMode / isolated
8
+ // workspace). The HTTP `POST /api/agents/spawn` route did NONE of it — it authorized
9
+ // on `agent:write` and emitted an ungated, un-parented spawn command. A spawn-capable
10
+ // token reaching HTTP bypassed the entire gate (masked today only because the dashboard
11
+ // uses an admin `*` token). That is exactly the "fixed in one transport, not the other"
12
+ // drift epic #342 kills.
13
+ //
14
+ // Transports now shrink to: parse wire → `authContextFrom*` → `spawnAgent` → serialize.
15
+ // Both the MCP and HTTP paths feed ONE `AuthContext`; identity (`ctx.callerAgentId`),
16
+ // scopes, and signed constraints are read identically, so the gate cannot diverge. The
17
+ // parity harness (spawn-agent.parity.test.ts) asserts both transports produce a
18
+ // byte-identical command payload + identical gate behavior through this service.
19
+ //
20
+ // NOTE — internal callers (managed-policy auto-spawn, automations, agent-session resume)
21
+ // are SYSTEM-actor spawns with their own lineage semantics (policy/scheduled, not
22
+ // agent-parented); the agent-caller gate genuinely no-ops for them. They stay on the
23
+ // shared `buildSpawnCommand`/`buildManagedSpawnParams` payload home directly. Folding
24
+ // them through `spawnAgent(systemCtx)` is a clean belt-and-suspenders follow-up (#342),
25
+ // deliberately out of scope here — this slice converges the two external TRANSPORTS.
26
+ import type { SpawnProvider } from "agent-relay-sdk";
27
+ import { stringValue } from "agent-relay-sdk";
28
+ import { countLiveSpawnedAgents, createActivityEvent, getAgent, listAgents, ValidationError } from "../db";
29
+ import { createCommand } from "../commands-db";
30
+ import { emitCommandEvent } from "../command-events";
31
+ import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
32
+ import { runnerRuntimeTokenEnv } from "../runtime-tokens";
33
+ import { selectSpawnOrchestrator } from "../spawn-targets";
34
+ import { getAgentProfile } from "../config-store";
35
+ import { hasComponentScope, isComponentAuthorizedFor } from "../security";
36
+ import { isPathWithinBase } from "../utils";
37
+ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
38
+ import type { AuthContext } from "./auth-context";
39
+ import type { AgentCard, Command, SpawnApprovalMode, WorkspaceMode } from "../types";
40
+
41
+ /** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
42
+ const SPAWN_SCOPE = "command:spawn";
43
+
44
+ /**
45
+ * A normalized spawn request — every transport validates its own wire shape (enums,
46
+ * lengths) and hands the service this. Caller-context defaults (cwd, approvalMode,
47
+ * workspaceMode, host) are resolved INSIDE the service from `ctx`, so `undefined`
48
+ * here means "not explicitly set; apply the unified default", never "transport forgot".
49
+ */
50
+ export interface SpawnAgentInput {
51
+ provider: SpawnProvider;
52
+ orchestratorId?: string;
53
+ cwd?: string;
54
+ /** Raw model/effort; resolved to a {model,providerModel,effort} payload by the service. */
55
+ model?: string;
56
+ effort?: string;
57
+ approvalMode?: SpawnApprovalMode;
58
+ workspaceMode?: WorkspaceMode;
59
+ label?: string;
60
+ policyName?: string;
61
+ profile?: string;
62
+ prompt?: string;
63
+ systemPromptAppend?: string;
64
+ tags?: string[];
65
+ capabilities?: string[];
66
+ providerArgs?: string[];
67
+ spawnRequestId?: string;
68
+ /** Transport provenance label baked into the command payload (e.g. "mcp", "dashboard"). */
69
+ requestedVia?: string;
70
+ /** Provenance actor override (the dashboard records "dashboard"); falls back to caller id / actor. */
71
+ requestedBy?: string;
72
+ /** Poll for the spawned child's registration this long (ms). 0 = fire-and-forget (HTTP). */
73
+ waitForRegistrationMs?: number;
74
+ }
75
+
76
+ export interface SpawnAgentResult {
77
+ ok: true;
78
+ spawnRequestId: string;
79
+ orchestratorId: string;
80
+ provider: SpawnProvider;
81
+ /** Resolved once the child registers with this spawnRequestId; null on timeout / no wait. */
82
+ agentId: string | null;
83
+ registered: boolean;
84
+ command: Command;
85
+ }
86
+
87
+ /** Reject when a component token's signed resource constraints disallow the target (#323). */
88
+ function assertResourceAllowed(ctx: AuthContext, check: Parameters<typeof isComponentAuthorizedFor>[1]): void {
89
+ if (ctx.component && !isComponentAuthorizedFor(ctx.component, check)) {
90
+ throw new McpAuthError("component token cannot access this resource");
91
+ }
92
+ }
93
+
94
+ /** True if the principal holds the spawn scope. System callers bypass; admin `*`/`admin:*` pass. */
95
+ function canSpawn(ctx: AuthContext): boolean {
96
+ if (ctx.actor.kind === "system") return true;
97
+ if (ctx.scopes.includes("*") || ctx.scopes.includes("admin:*")) return true;
98
+ if (ctx.component) return hasComponentScope(ctx.component, SPAWN_SCOPE);
99
+ return ctx.scopes.includes(SPAWN_SCOPE);
100
+ }
101
+
102
+ /**
103
+ * Spawn a provider agent. Transport-agnostic: every gate + side effect lives here so the
104
+ * MCP and HTTP paths cannot drift. Throws `McpAuthError` (→403) on a gate denial,
105
+ * `ValidationError` (→400) on bad input/quota, `McpNotFoundError` (→404) on a missing
106
+ * orchestrator. With `waitForRegistrationMs > 0` it resolves the child's agent id by
107
+ * polling its registration; otherwise it returns immediately (fire-and-forget).
108
+ */
109
+ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Promise<SpawnAgentResult> {
110
+ // The authenticated caller's own agent card (when the token resolves to one). Drives both
111
+ // the #221 gate AND the #328/#331/#324 caller-context inheritance — one lookup, reused.
112
+ const callerId = ctx.callerAgentId;
113
+ const caller: AgentCard | undefined = (callerId ? getAgent(callerId) : undefined) ?? undefined;
114
+
115
+ // #255/#328 — bias the host to the caller's own machine when neither an explicit
116
+ // orchestratorId nor a cwd pins it, so an agent spawning a helper lands on its own host.
117
+ const orchestrator = selectSpawnOrchestrator(input.provider, input.orchestratorId, input.cwd, caller?.machine);
118
+
119
+ // #308 §3 — an explicit cwd must resolve within the TARGET host's base dir (a path valid on
120
+ // your host may not exist on a different orchestrator).
121
+ if (input.cwd && !isPathWithinBase(input.cwd, orchestrator.baseDir)) {
122
+ throw new ValidationError(`cwd '${input.cwd}' is not within ${orchestrator.id} (host ${orchestrator.hostname})'s base dir '${orchestrator.baseDir}' — a path valid on your host may not exist on the target. Pass a cwd under that base dir, or omit cwd to default to it.`);
123
+ }
124
+ // #328 — default cwd to the caller's OWN repo (so "spawn a helper for my current task" Just
125
+ // Works, esp. isolated mode which needs a git repo), but only when it resolves within the
126
+ // target host's base. Precedence: explicit cwd > caller cwd > base dir.
127
+ const callerCwd = stringValue(caller?.meta?.cwd);
128
+ const inheritedCwd = callerCwd && isPathWithinBase(callerCwd, orchestrator.baseDir) ? callerCwd : undefined;
129
+ const resolvedCwd = input.cwd || inheritedCwd || orchestrator.baseDir;
130
+
131
+ const modelParams = resolveSpawnModelParams(input.provider, input.model, input.effort);
132
+
133
+ // #331 — default the child's approval mode to the CALLER's, not a hardcoded `guarded` (a
134
+ // headless `guarded` child wedges on its first tool-call approval prompt). Explicit always
135
+ // wins and can NARROW a child; non-agent callers keep the safe `guarded` default.
136
+ const callerApprovalMode = caller?.meta?.approvalMode as SpawnApprovalMode | undefined;
137
+ const approvalMode = input.approvalMode ?? callerApprovalMode ?? "guarded";
138
+
139
+ // #324 — agent-initiated spawns (a real caller behind the token) default to `isolated` (a
140
+ // branch worker that auto-lands); non-agent callers (admin/dashboard) keep the orchestrator's
141
+ // `inherit` fallback. Explicit wins.
142
+ const workspaceMode = input.workspaceMode ?? (callerId ? ("isolated" as WorkspaceMode) : undefined);
143
+
144
+ const spawnRequestId = input.spawnRequestId ?? generateSpawnRequestId();
145
+
146
+ // Resolve + validate the named agent profile server-side (was HTTP-only; the MCP path passed
147
+ // the name through unvalidated). Both transports now 404 on an unknown profile and ship the
148
+ // resolved value to the orchestrator.
149
+ const agentProfile = input.profile ? getAgentProfile(input.profile)?.value : undefined;
150
+ if (input.profile && !agentProfile) throw new McpNotFoundError("agent profile not found");
151
+
152
+ // --- THE GATE (uniform across transports) ---------------------------------
153
+ // Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
154
+ // this; the HTTP route did not — asserting here makes it transport-invariant.
155
+ if (!canSpawn(ctx)) {
156
+ throw new McpAuthError(`spawn requires the ${SPAWN_SCOPE} scope`);
157
+ }
158
+ // #221 — a real spawning agent: no grandchildren, and within its maxSpawnedAgents quota.
159
+ // Server/admin/dashboard tokens carry no caller identity → unrestricted by design.
160
+ if (callerId) {
161
+ if (caller?.spawnedBy) {
162
+ throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
163
+ }
164
+ const quota = ctx.constraints?.maxSpawnedAgents ?? 0;
165
+ const live = countLiveSpawnedAgents(callerId);
166
+ if (live >= quota) {
167
+ throw new ValidationError(`spawn quota reached (${live}/${quota} live children) — shut one down or wait for one to exit`);
168
+ }
169
+ }
170
+ // Gate the child spawn on the token's legit bounds: the target orchestrator AND the spawn cwd
171
+ // (`cwdPrefixes`/`cwd` constraint). Excludes the #323 footguns (spawnRequestIds/policies) — those
172
+ // are self-scoping per-spawn ids the CHILD gets fresh, so gating on them makes the quota
173
+ // unreachable. The cwd bound was enforced by the old HTTP route (resource.cwd) but NOT by the MCP
174
+ // path; unifying here converges UP to the strict union — a scoped dashboard token still can't spawn
175
+ // outside its cwd prefix, and MCP now honors the same bound.
176
+ assertResourceAllowed(ctx, { scope: "agent:write", resource: { orchestratorId: orchestrator.id, cwd: resolvedCwd } });
177
+
178
+ // Provenance actor: the resolved caller agent when known, else the transport's label, else
179
+ // the raw token actor. Recorded in the command payload, the token mint, and the audit row.
180
+ const requestedBy = callerId ?? input.requestedBy ?? ctx.actor.id;
181
+
182
+ // Child runner token. `agentInitiated` (set whenever an agent is the caller) forces a
183
+ // non-spawn-capable child (no grandchildren); `spawnedBy` stamps authoritative lineage the
184
+ // child can't forge (it registers with spawnedBy = caller, read from the signed token).
185
+ const env = runnerRuntimeTokenEnv({
186
+ orchestratorId: orchestrator.id,
187
+ cwd: resolvedCwd,
188
+ provider: input.provider,
189
+ label: input.label,
190
+ policyName: input.policyName,
191
+ spawnRequestId,
192
+ createdBy: requestedBy,
193
+ // Thread the resolved profile into the mint so the spawn grant (command:spawn/shutdown +
194
+ // maxSpawnedAgents quota) is honored — parity with the HTTP path (agent-sessions.ts). #349
195
+ // dropped this when it unified the spawn service, silently un-spawning every default-spawner
196
+ // (P0 regression #364). For an agent caller, agentInitiated still forces a non-spawn-capable
197
+ // child regardless of profile (no grandchildren).
198
+ profile: input.profile || undefined,
199
+ ...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
200
+ });
201
+
202
+ const command = createCommand({
203
+ type: "agent.spawn",
204
+ source: "system",
205
+ target: orchestrator.agentId,
206
+ correlationId: spawnRequestId,
207
+ params: buildSpawnCommand({
208
+ provider: input.provider,
209
+ modelParams,
210
+ cwd: resolvedCwd,
211
+ ...(workspaceMode ? { workspaceMode } : {}),
212
+ label: input.label,
213
+ profile: input.profile || undefined,
214
+ agentProfile,
215
+ tags: input.tags ?? [],
216
+ capabilities: input.capabilities ?? [],
217
+ approvalMode,
218
+ permissionMode: approvalMode,
219
+ providerArgs: input.providerArgs ?? [],
220
+ prompt: input.prompt,
221
+ systemPromptAppend: input.systemPromptAppend,
222
+ policyName: input.policyName,
223
+ spawnRequestId,
224
+ env,
225
+ requestedBy,
226
+ requestedVia: input.requestedVia,
227
+ requestedAt: Date.now(),
228
+ orchestratorId: orchestrator.id,
229
+ // #308 — stamp the spawning parent so a FAILED agent.spawn command (child never registers,
230
+ // so there's no agent row to resolve `spawnedBy` from) can be routed back to it.
231
+ ...(callerId ? { extra: { spawnedBy: callerId } } : {}),
232
+ }),
233
+ });
234
+ emitCommandEvent(command, "command.requested");
235
+
236
+ // ONE spawn-request audit row, identical across transports (the MCP path previously emitted
237
+ // none of its own — only the generic tool-call telemetry; the HTTP path emitted one).
238
+ createActivityEvent({
239
+ clientId: `server-agent-spawn-${input.provider}-${command.id}`,
240
+ kind: "state",
241
+ title: `${input.provider} agent spawn requested (via ${orchestrator.id})`,
242
+ body: resolvedCwd,
243
+ meta: orchestrator.id,
244
+ icon: "ti-plus",
245
+ view: "agents",
246
+ metadata: {
247
+ provider: input.provider,
248
+ orchestratorId: orchestrator.id,
249
+ approvalMode,
250
+ workspaceMode: workspaceMode ?? null,
251
+ label: input.label ?? null,
252
+ commandId: command.id,
253
+ spawnRequestId,
254
+ requestedVia: input.requestedVia ?? null,
255
+ actor: requestedBy,
256
+ actorKind: ctx.actor.kind,
257
+ },
258
+ });
259
+
260
+ // #255 — resolve the spawned child's id once it registers back to THIS relay (same DB) with
261
+ // meta.spawnRequestId set. Bounded poll; 0 opts out (pure fire-and-forget, e.g. the HTTP 202).
262
+ const waitMs = Math.min(input.waitForRegistrationMs ?? 0, 30_000);
263
+ const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
264
+ return {
265
+ ok: true,
266
+ spawnRequestId,
267
+ orchestratorId: orchestrator.id,
268
+ provider: input.provider,
269
+ agentId,
270
+ registered: agentId !== null,
271
+ command,
272
+ };
273
+ }
274
+
275
+ /** Poll the agents table for the child that registers with this spawnRequestId (#255). */
276
+ async function waitForSpawnedAgent(spawnRequestId: string, timeoutMs: number, pollMs = 300): Promise<string | null> {
277
+ const deadline = Date.now() + timeoutMs;
278
+ for (;;) {
279
+ const match = listAgents().find((a) => a.meta?.spawnRequestId === spawnRequestId);
280
+ if (match) return match.id;
281
+ if (Date.now() >= deadline) return null;
282
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
283
+ }
284
+ }
@@ -25,7 +25,7 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
25
25
  export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
26
26
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
27
27
  import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
28
- import type { Command, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
28
+ import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
29
29
 
30
30
  // Single source of truth for the action verb set. The route's `optionalEnum` and
31
31
  // the MCP tool surface both import this so they can never drift out of sync.
@@ -54,6 +54,8 @@ interface ApplyWorkspaceActionInput {
54
54
  deleteBranch?: boolean;
55
55
  prTitle?: string;
56
56
  prBody?: string;
57
+ autoMerge?: WorkspaceAutoMergePolicy;
58
+ reviewer?: string;
57
59
  // cleanup
58
60
  force?: boolean;
59
61
  // claim / release-claim
@@ -171,6 +173,8 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
171
173
  deleteBranch: input.deleteBranch !== false,
172
174
  prTitle: input.prTitle,
173
175
  prBody: input.prBody,
176
+ autoMerge: input.autoMerge,
177
+ reviewer: input.reviewer,
174
178
  metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
175
179
  });
176
180
  if (!result.ok) return { ok: false, httpStatus: result.status, error: result.error };