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
package/src/mcp.ts CHANGED
@@ -1,17 +1,15 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
3
- import { createCommand } from "./commands-db";
4
- import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
3
+ import { spawnAgent, type SpawnAgentInput } from "./services/spawn-agent";
4
+ import { authContextFromMcp } from "./services/auth-context";
5
5
  import { isPathWithinBase } from "./utils";
6
6
  import { optionalEnum } from "./validation";
7
- import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
8
7
  import { bytesToStream, readBodyBytes } from "./http-body";
9
8
  import { MAX_BODY_BYTES, VERSION } from "./config";
10
9
  import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
11
10
  import { buildSpawnTargets, selectSpawnOrchestrator, spawnCapablePrimer } from "./spawn-targets";
12
11
  import { McpAuthError, McpNotFoundError } from "./mcp-errors";
13
12
  import {
14
- countLiveSpawnedAgents,
15
13
  createArtifact,
16
14
  createActivityEvent,
17
15
  getAgent,
@@ -31,27 +29,26 @@ import {
31
29
  type AgentSearchSort,
32
30
  listArtifactsForEntity,
33
31
  listOrchestrators,
34
- sendMessageWithResult,
35
32
  upsertArtifactBlob,
36
33
  ValidationError,
37
34
  } from "./db";
38
- import { planSend, type DeliveryReceipt } from "./agent-ref";
35
+ import { resolveCallerAgentId, type DeliveryReceipt } from "./agent-ref";
36
+ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "./services/shutdown-agent";
39
37
  import { emitRelayEvent } from "./events";
40
- import { emitMessageQueued, emitNewMessage } from "./sse";
41
38
  import {
42
39
  getComponentAuth,
43
40
  getIntegrationAuth,
44
41
  hasComponentScope,
45
42
  hasIntegrationScope,
46
43
  isComponentAuthorizedFor,
47
- isIntegrationAllowed,
48
44
  } from "./security";
49
- import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
45
+ import { sendMessageService } from "./services/send-message";
46
+ import { ServiceAuthError } from "./services/errors";
47
+ import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
50
48
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
51
49
  import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
52
50
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
53
51
  import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
54
- import { runnerRuntimeTokenEnv } from "./runtime-tokens";
55
52
 
56
53
  type JsonRpcId = string | number | null;
57
54
 
@@ -94,7 +91,7 @@ const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep
94
91
  const TOOLS: ToolDefinition[] = [
95
92
  {
96
93
  name: "relay_send_message",
97
- description: "Send an Agent Relay message. Returns a delivery receipt (delivered/expectReply/recipients): if expectReply is false, no live recipient exists — don't wait for a reply. Unknown or ambiguous targets are rejected up front, never silently dropped.",
94
+ description: "Send an Agent Relay message. Returns a delivery receipt (delivered/expectReply/recipients): if expectReply is false, no live recipient exists — don't wait for a reply. An ambiguous target is rejected up front. An unknown target is stored for delivery once that id registers (the receipt reports delivered:false with empty recipients and a reason) — never silently dropped.",
98
95
  requiredScopes: ["messages:write", "message:send"],
99
96
  inputSchema: {
100
97
  type: "object",
@@ -531,41 +528,18 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
531
528
  // (see issueMcpRuntimeToken). That single allowed agent IS the caller's identity, and the
532
529
  // security layer already treats it as the only `from` this token may use. Derive it so
533
530
  // agents never need to know — or type — their own id.
534
- function senderIdentity(auth: McpAuthContext): string | undefined {
535
- if (auth.kind !== "component") return undefined;
536
- const agents = auth.component?.constraints?.agents;
537
- return agents?.length === 1 ? agents[0] : undefined;
538
- }
539
-
540
- // THE caller-identity resolver: the agent id behind this token, for `from`-autofill,
541
- // relay_whoami, and spawn/shutdown gating (#221, #243). `senderIdentity` covers
542
- // identity-bearing tokens (interactive/mcp, constraints.agents). Managed agents spawned by
543
- // the orchestrator authenticate with a runner token that carries no `agents` constraint but
544
- // DOES carry its spawnRequestId/policy — resolve those back to the registered agent card so
545
- // they never need to pass `from`. Returns undefined for server/admin tokens (unrestricted by
546
- // design) and multi-agent tokens. Keep `resolveSender`/`relayWhoami` on THIS, not the narrower
547
- // `senderIdentity`, or managed agents silently lose implicit identity again (the #243 drift).
531
+ // THE caller-identity resolver for MCP: the agent id behind this token, for `from`-autofill,
532
+ // relay_whoami, and spawn/shutdown gating (#221, #243). Delegates to the shared
533
+ // `resolveCallerAgentId` (one home, src/agent-ref.ts) so the bus/HTTP/MCP transports resolve
534
+ // caller identity identically managed agents (runner token carrying spawnRequestId/policy,
535
+ // no `agents` constraint) resolve back to their registered card; server/admin/multi-agent
536
+ // tokens return undefined. Keep `relay_whoami` + the spawn/shutdown gates on THIS, not a
537
+ // narrower check, or managed agents silently lose implicit identity again (the #243 drift).
538
+ // (The send/reply `from`-autofill now flows through the shared send service's `ctx.callerAgentId`,
539
+ // populated by `authContextFromMcp` from this same resolver — one home across all transports.)
548
540
  function callerAgentId(auth: McpAuthContext): string | undefined {
549
- const direct = senderIdentity(auth);
550
- if (direct) return direct;
551
541
  if (auth.kind !== "component") return undefined;
552
- const c = auth.component?.constraints;
553
- const spawnRequestId = c?.spawnRequestIds?.length === 1 ? c.spawnRequestIds[0] : undefined;
554
- const policyName = c?.policies?.length === 1 ? c.policies[0] : undefined;
555
- if (!spawnRequestId && !policyName) return undefined;
556
- const match = listAgents().find((a) =>
557
- (spawnRequestId !== undefined && a.meta?.spawnRequestId === spawnRequestId) ||
558
- (policyName !== undefined && a.meta?.policyName === policyName));
559
- return match?.id;
560
- }
561
-
562
- function resolveSender(auth: McpAuthContext, rawFrom: unknown): string {
563
- // Token identity wins and cannot be spoofed; any provided `from` is ignored when known.
564
- // Resolves both constraints.agents tokens AND spawn/policy-managed agents (#243).
565
- const identity = callerAgentId(auth);
566
- if (identity) return identity;
567
- // Server/integration/multi-agent tokens carry no single identity — keep requiring `from`.
568
- return stringField(rawFrom, "from", { required: true, max: 200 });
542
+ return resolveCallerAgentId(auth.component?.constraints, listAgents);
569
543
  }
570
544
 
571
545
  function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
@@ -580,20 +554,33 @@ function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
580
554
  };
581
555
  }
582
556
 
557
+ // MCP transport adapter over the shared send service (epic #342). The service owns from-resolution
558
+ // (token identity via ctx.callerAgentId), reply routing, target resolution + the converged
559
+ // store-ahead policy, authorization, persistence, and emit. This adapter only builds the input,
560
+ // the AuthContext (carrying the integration token separately, per the lean-AuthContext contract),
561
+ // and maps the service's typed ServiceAuthError onto the MCP wire error (McpAuthError → 403).
562
+ function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
563
+ try {
564
+ const result = sendMessageService(
565
+ input,
566
+ authContextFromMcp({ kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component }),
567
+ { integration: auth.integration },
568
+ );
569
+ return { ...result.message, delivery: result.receipt };
570
+ } catch (e) {
571
+ if (e instanceof ServiceAuthError) throw new McpAuthError(e.message);
572
+ throw e; // AmbiguousTargetError/ValidationError → invalid-params via the central JSON-RPC map.
573
+ }
574
+ }
575
+
583
576
  function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
584
577
  const attachments = optionalAttachments(args.attachments);
585
578
  const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
586
- const requestedTo = stringField(args.to, "to", { required: true, max: 200 });
587
- const sender = resolveSender(auth, args.from);
588
- // Resolve the target to a canonical agent id (so poll-time matching works) and refuse
589
- // up front when it's unknown or ambiguous — never store a message no one will receive.
590
- // Exclude the sender so a bare ref can't loop back to its own author (#290).
591
- const plan = planSend(requestedTo, listAgents(), { excludeId: sender });
592
- if (plan.kind === "not_found") throw new McpNotFoundError(plan.message);
593
- if (plan.kind === "ambiguous") throw new ValidationError(plan.message);
594
579
  const input: SendMessageInput = {
595
- from: sender,
596
- to: plan.to,
580
+ // `from` is resolved by the service from the token identity (callerAgentId wins); the wire
581
+ // value is only a fallback for identity-less tokens.
582
+ from: optionalString(args.from, "from", 200) ?? "",
583
+ to: stringField(args.to, "to", { required: true, max: 200 }),
597
584
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
598
585
  subject: optionalString(args.subject, "subject", 200),
599
586
  channel: optionalString(args.channel, "channel", 120),
@@ -603,11 +590,7 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
603
590
  payload,
604
591
  meta: optionalRecord(args.meta, "meta"),
605
592
  };
606
- assertIntegrationTargetAllowed(auth, input.to, input.channel);
607
- assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
608
- const result = sendMessageWithResult(input);
609
- emitMessage(result.message, result.created);
610
- return { ...result.message, delivery: plan.receipt };
593
+ return runMcpSend(auth, input);
611
594
  }
612
595
 
613
596
  function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
@@ -620,30 +603,20 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
620
603
  const replyPayload = payloadWithAttachments({
621
604
  ...payload,
622
605
  ...(format ? { message: { ...(isRecord(payload.message) ? payload.message : {}), format } } : {}),
623
- ...replyContext(parent),
624
606
  }, attachments);
625
607
  const input: SendMessageInput = {
626
- from: resolveSender(auth, args.from),
627
- to: parent.from,
608
+ from: optionalString(args.from, "from", 200) ?? "",
609
+ // Empty `to` + replyTo lets the service auto-route to the parent's sender, inherit its
610
+ // channel, and propagate channel replyContext — identical to the HTTP reply path.
611
+ to: "",
628
612
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
629
613
  subject: optionalString(args.subject, "subject", 200),
630
- channel: parent.channel,
631
614
  replyTo: parent.id,
632
615
  attachments,
633
616
  payload: replyPayload,
634
617
  meta: optionalRecord(args.meta, "meta"),
635
618
  };
636
- assertIntegrationTargetAllowed(auth, input.to, input.channel);
637
- assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
638
- const result = sendMessageWithResult(input);
639
- emitMessage(result.message, result.created);
640
- // Reply routing is fixed to the parent's sender — never reject, but report whether
641
- // that original sender is still reachable so the agent doesn't wait forever.
642
- const plan = planSend(input.to, listAgents(), { excludeId: input.from });
643
- const delivery: DeliveryReceipt = plan.kind === "not_found" || plan.kind === "ambiguous"
644
- ? { delivered: false, expectReply: false, recipients: [], reason: "original sender no longer reachable" }
645
- : plan.receipt;
646
- return { ...result.message, delivery };
619
+ return runMcpSend(auth, input);
647
620
  }
648
621
 
649
622
  function relayGetMessage(args: Record<string, unknown>): Record<string, unknown> {
@@ -788,136 +761,34 @@ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): R
788
761
  }
789
762
 
790
763
  async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
791
- const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
792
- const cwd = optionalString(args.cwd, "cwd", 500);
793
- const callerId = callerAgentId(auth);
794
- // One caller-record lookup, reused for host preference (#221), the cwd default (#328) and the
795
- // approvalMode default (#331) an agent spawning a helper inherits its own context instead of
796
- // falling back to hardcoded values.
797
- const caller = callerId ? getAgent(callerId) : undefined;
798
- const preferHost = caller?.machine;
799
- const orchestrator = selectSpawnOrchestrator(provider, optionalString(args.orchestratorId, "orchestratorId", 200), cwd, preferHost);
800
- // #328 — default cwd to the caller's OWN cwd (the repo it's working in), not the orchestrator
801
- // base dir, so "agent spawns a helper for its current task" Just Works — especially isolated mode,
802
- // which needs a git repo (the base dir usually isn't one, so it silently downgraded to shared).
803
- // Only adopt the caller's cwd when it resolves within the TARGET host's base dir (preferHost
804
- // already biases the target to the caller's host; a cross-host path may not exist there). Non-agent
805
- // callers (no caller record) keep the base-dir fallback. Precedence: explicit cwd > caller cwd > base dir.
806
- const callerCwd = stringValue(caller?.meta?.cwd);
807
- const inheritedCwd = callerCwd && isPathWithinBase(callerCwd, orchestrator.baseDir) ? callerCwd : undefined;
808
- const resolvedCwd = cwd || inheritedCwd || orchestrator.baseDir;
809
- // #308 §3 — cwd must resolve within the TARGET host's base dir. A path valid on your own host
810
- // may not exist on a different orchestrator, so validate against the chosen host and say which.
811
- if (cwd && !isPathWithinBase(cwd, orchestrator.baseDir)) {
812
- throw new ValidationError(`cwd '${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.`);
813
- }
814
- const selection = providerSelection(provider, args);
815
- // #331 default the child's approval mode to the CALLER's, not a hardcoded `guarded`. A headless
816
- // `guarded` child wedges on the first tool-call approval prompt (no human at the TUI — it can't even
817
- // read its own spawn message). A trusted coordinator running `open` spawns workers that can actually
818
- // work in their isolated worktrees; an explicit arg always wins and can NARROW a child (e.g. a
819
- // read-only reviewer); non-agent/admin callers (no caller record) keep the safe `guarded` default.
820
- // Precedence: explicit approvalMode > caller mode > guarded.
821
- const callerApprovalMode = optionalEnum(stringValue(caller?.meta?.approvalMode), "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined;
822
- const approvalMode = (optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined)
823
- ?? callerApprovalMode
824
- ?? "guarded";
825
- const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? generateSpawnRequestId();
826
- const label = optionalString(args.label, "label", 120);
827
- const policyName = optionalString(args.policyName, "policyName", 120);
828
- const profile = optionalString(args.profile, "profile", 120);
829
- // #324 — expose the workspace knob on the MCP spawn surface (payload home always supported it; the
830
- // handler just never passed it → orchestrator fell back to `inherit`→`shared`, so a worker edited the
831
- // CALLER's live tree). Agent-initiated spawns (real caller behind the token) default to `isolated` (a
832
- // branch worker that auto-lands); non-agent callers (admin/server) keep `inherit`. Explicit wins.
833
- const workspaceMode = (optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined) ?? (callerId ? "isolated" : undefined);
834
-
835
- // #221 runtime gate (belt; the coarse `command:spawn` scope is enforced in callTool, and is
836
- // granted only to agents whose profile sets maxSpawnedAgents>0 and never to children).
837
- // Server/admin tokens have no caller identity → unrestricted by design.
838
- if (callerId) {
839
- if (caller?.spawnedBy) {
840
- throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
841
- }
842
- const quota = auth.component?.constraints?.maxSpawnedAgents ?? 0;
843
- const live = countLiveSpawnedAgents(callerId);
844
- if (live >= quota) {
845
- throw new ValidationError(`spawn quota reached (${live}/${quota} live children) — shut one down or wait for one to exit`);
846
- }
847
- }
848
-
849
- // #323 — gate child spawn only on `orchestrators` (the parent's legit bound), NOT its self-scoping
850
- // spawnRequestIds/cwdPrefixes/policies: those describe the child, not parent-owned resources, so
851
- // gating on them makes maxSpawnedAgents unreachable for every component token (cwd checked above).
852
- assertComponentResourceAllowed(auth, { scope: "agent:write", resource: { orchestratorId: orchestrator.id } });
853
-
854
- // Child runner token: a normal long-living agent that is NOT itself spawn-capable
855
- // (canSpawn:false → no grandchildren), stamped with authoritative lineage so it registers
856
- // with spawnedBy = caller (the child can't forge it; it's read from the signed token).
857
- const env = runnerRuntimeTokenEnv({
858
- orchestratorId: orchestrator.id,
859
- cwd: resolvedCwd,
860
- provider,
861
- label,
862
- policyName,
863
- spawnRequestId,
864
- createdBy: callerId ?? auth.actor,
865
- agentInitiated: true,
866
- ...(callerId ? { spawnedBy: callerId } : {}),
867
- });
868
- const command = createCommand({
869
- type: "agent.spawn",
870
- source: "system",
871
- target: orchestrator.agentId,
872
- correlationId: spawnRequestId,
873
- params: buildSpawnCommand({
874
- provider,
875
- modelParams: selection,
876
- cwd: resolvedCwd,
877
- ...(workspaceMode ? { workspaceMode } : {}),
878
- label,
879
- profile: profile || undefined,
880
- tags: optionalStringArray(args.tags, "tags") ?? [],
881
- capabilities: optionalStringArray(args.capabilities, "capabilities") ?? [],
882
- approvalMode,
883
- permissionMode: approvalMode,
884
- providerArgs: optionalStringArray(args.providerArgs, "providerArgs") ?? [],
885
- prompt: optionalString(args.prompt, "prompt", 16_000),
886
- systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
887
- policyName,
888
- spawnRequestId,
889
- env,
890
- requestedBy: auth.actor,
891
- requestedVia: "mcp",
892
- requestedAt: Date.now(),
893
- orchestratorId: orchestrator.id,
894
- // #308 — stamp the spawning parent so a failed agent.spawn command can be routed back to
895
- // it (the child never registers, so there's no agent record to resolve `spawnedBy` from).
896
- ...(callerId ? { extra: { spawnedBy: callerId } } : {}),
897
- }),
898
- });
899
- emitCommand(command);
900
-
901
- // #255: resolve the spawned agent id once it registers. Spawn is a fire-and-forget command
902
- // over the bus; the child registers back to THIS relay (same DB) with meta.spawnRequestId set,
903
- // so a bounded poll links the request to the agent without a separate relay_find_agents round
904
- // trip. waitForRegistrationMs:0 opts out (pure fire-and-forget); the default is short because
905
- // isolated-worktree spawns register near-instantly (symlinked deps).
906
- const waitMs = Math.min(optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000, 30000);
907
- const agentId = waitMs > 0 ? await waitForSpawnedAgent(spawnRequestId, waitMs) : null;
908
- return { ok: true, spawnRequestId, orchestratorId: orchestrator.id, provider, agentId, registered: agentId !== null, command };
909
- }
910
-
911
- // Poll the agents table for the child that registers with this spawnRequestId (#255). Returns
912
- // the resolved agent id, or null on timeout (the caller still has spawnRequestId to poll later).
913
- async function waitForSpawnedAgent(spawnRequestId: string, timeoutMs: number, pollMs = 300): Promise<string | null> {
914
- const deadline = Date.now() + timeoutMs;
915
- for (;;) {
916
- const match = listAgents().find((a) => a.meta?.spawnRequestId === spawnRequestId);
917
- if (match) return match.id;
918
- if (Date.now() >= deadline) return null;
919
- await new Promise<void>((resolve) => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
920
- }
764
+ // Thin transport: parse wire build AuthContext → call the spawnAgent service → serialize.
765
+ // ALL policy + side effects (caller-context inheritance, the #221 no-grandchild + quota gate,
766
+ // the #323 resource gate, the command:spawn scope assert, authoritative lineage stamping, the
767
+ // command payload + emit + audit) live in src/services/spawn-agent.ts so the MCP and HTTP spawn
768
+ // paths cannot drift (epic #342). `waitForRegistrationMs` defaults to a short wait here because
769
+ // isolated-worktree spawns register near-instantly (symlinked deps); 0 opts out.
770
+ const input: SpawnAgentInput = {
771
+ provider: enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
772
+ orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
773
+ cwd: optionalString(args.cwd, "cwd", 500),
774
+ model: optionalString(args.model, "model", 120),
775
+ effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
776
+ approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
777
+ workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
778
+ label: optionalString(args.label, "label", 120),
779
+ policyName: optionalString(args.policyName, "policyName", 120),
780
+ profile: optionalString(args.profile, "profile", 120),
781
+ prompt: optionalString(args.prompt, "prompt", 16_000),
782
+ systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
783
+ tags: optionalStringArray(args.tags, "tags"),
784
+ capabilities: optionalStringArray(args.capabilities, "capabilities"),
785
+ providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
786
+ spawnRequestId: optionalString(args.spawnRequestId, "spawnRequestId", 160),
787
+ requestedVia: "mcp",
788
+ waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000,
789
+ };
790
+ const result = await spawnAgent(input, authContextFromMcp(auth));
791
+ return { ...result };
921
792
  }
922
793
 
923
794
  function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
@@ -929,50 +800,32 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
929
800
  throw new ValidationError("agentId, policyName, spawnRequestId, or tmuxSession required");
930
801
  }
931
802
 
932
- // #221: an agent caller may only shut down its OWN live spawned children, addressed by
933
- // agentId. Broad targeting (policy/spawnRequestId/tmux) and cross-agent kills stay admin-only
934
- // otherwise spawn permission silently becomes kill-anyone permission. Server/admin tokens
935
- // (no caller identity) keep full reach.
936
- const callerId = callerAgentId(auth);
937
- if (callerId) {
938
- if (!agentId || policyName || spawnRequestId || tmuxSession) {
939
- throw new McpAuthError("agents may only shut down their own spawned children, addressed by agentId");
940
- }
941
- const target = getAgent(agentId);
942
- if (!target || target.spawnedBy !== callerId) {
943
- throw new McpAuthError(`agent ${agentId} is not one of your spawned children`);
944
- }
803
+ // The #221 parent→child gate, control-orchestrator resolution, and the canonical
804
+ // agent.shutdown payload + side-effects all live in the shutdownAgent service now —
805
+ // shared byte-for-byte with the HTTP route and the bus command frame (epic #342, #347).
806
+ // Map the service's transport-neutral errors back to MCP's JSON-RPC error codes.
807
+ try {
808
+ const result = shutdownAgent(
809
+ {
810
+ agentId,
811
+ policyName,
812
+ spawnRequestId,
813
+ tmuxSession,
814
+ orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
815
+ graceful: true,
816
+ timeoutMs: optionalPositiveInt(args.timeoutMs, "timeoutMs") ?? 10_000,
817
+ reason: optionalString(args.reason, "reason", 200) ?? "mcp-shutdown",
818
+ requestedVia: "mcp",
819
+ requestedBy: auth.actor,
820
+ },
821
+ authContextFromMcp(auth),
822
+ );
823
+ return { ok: true, action: "shutdown", orchestratorId: result.orchestratorId, command: result.command };
824
+ } catch (e) {
825
+ if (e instanceof ShutdownAuthError) throw new McpAuthError(e.message);
826
+ if (e instanceof ShutdownTargetError) throw new McpNotFoundError(e.message);
827
+ throw e;
945
828
  }
946
- const orchestrator = selectControlOrchestrator({
947
- orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
948
- agentId,
949
- policyName,
950
- spawnRequestId,
951
- tmuxSession,
952
- });
953
- const timeoutMs = optionalPositiveInt(args.timeoutMs, "timeoutMs") ?? 10_000;
954
- const command = createCommand({
955
- type: "agent.shutdown",
956
- source: "system",
957
- target: orchestrator.agentId,
958
- correlationId: spawnRequestId,
959
- params: {
960
- action: "shutdown",
961
- agentId,
962
- policyName,
963
- spawnRequestId,
964
- tmuxSession,
965
- graceful: true,
966
- timeoutMs,
967
- reason: optionalString(args.reason, "reason", 200) ?? "mcp-shutdown",
968
- requestedBy: auth.actor,
969
- requestedVia: "mcp",
970
- requestedAt: Date.now(),
971
- orchestratorId: orchestrator.id,
972
- },
973
- });
974
- emitCommand(command);
975
- return { ok: true, action: "shutdown", orchestratorId: orchestrator.id, command };
976
829
  }
977
830
 
978
831
  // --- Workspace lifecycle tools (#215) -------------------------------------
@@ -1088,16 +941,6 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
1088
941
  return payload;
1089
942
  }
1090
943
 
1091
- function replyContext(parent: Message): Record<string, unknown> {
1092
- const parentPayload = parent.payload ?? {};
1093
- if (parentPayload.schema !== "agent-relay.channel.v1" && !parentPayload.conversation) return {};
1094
- const context: Record<string, unknown> = {};
1095
- if (parent.channel) context.channelId = parent.channel;
1096
- if (isRecord(parentPayload.conversation)) context.conversationId = parentPayload.conversation.id;
1097
- if (isRecord(parentPayload.event)) context.parentEventId = parentPayload.event.id;
1098
- if (parentPayload.source) context.source = parentPayload.source;
1099
- return { replyContext: context };
1100
- }
1101
944
 
1102
945
  function payloadWithAttachments(
1103
946
  payload: Record<string, unknown> | undefined,
@@ -1146,45 +989,6 @@ function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
1146
989
  });
1147
990
  }
1148
991
 
1149
- function selectControlOrchestrator(input: {
1150
- orchestratorId?: string;
1151
- agentId?: string;
1152
- policyName?: string;
1153
- spawnRequestId?: string;
1154
- tmuxSession?: string;
1155
- }): NonNullable<ReturnType<typeof getOrchestrator>> {
1156
- if (input.orchestratorId) {
1157
- const orchestrator = getOrchestrator(input.orchestratorId);
1158
- if (!orchestrator) throw new McpNotFoundError(`orchestrator ${input.orchestratorId} not found`);
1159
- if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
1160
- return orchestrator;
1161
- }
1162
- const agent = input.agentId ? getAgent(input.agentId) : null;
1163
- const orchestrator = agent ? managedControlOrchestrator(agent, input) : (listManagedOrchestratorsForAgent(input)[0] ?? null);
1164
- if (!orchestrator) throw new McpNotFoundError("no orchestrator found for agent control target");
1165
- if (orchestrator.status !== "online") throw new ValidationError("orchestrator is offline");
1166
- return orchestrator;
1167
- }
1168
-
1169
- function managedControlOrchestrator(
1170
- agent: AgentCard,
1171
- input: { policyName?: string; spawnRequestId?: string; tmuxSession?: string },
1172
- ): NonNullable<ReturnType<typeof getOrchestrator>> | null {
1173
- const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
1174
- return listManagedOrchestratorsForAgent({
1175
- agentId: agent.id,
1176
- policyName: input.policyName ?? str(agent.meta?.policyName),
1177
- spawnRequestId: input.spawnRequestId ?? str(agent.meta?.spawnRequestId),
1178
- tmuxSession: input.tmuxSession ?? str(agent.meta?.tmuxSession),
1179
- })[0] ?? (agent.machine ? getOrchestrator(agent.machine) : null);
1180
- }
1181
-
1182
- function providerSelection(provider: SpawnProvider, args: Record<string, unknown>): SpawnModelParams {
1183
- const model = optionalString(args.model, "model", 120);
1184
- const effort = optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
1185
- return resolveSpawnModelParams(provider, model, effort);
1186
- }
1187
-
1188
992
  function artifactBytes(args: Record<string, unknown>): Uint8Array {
1189
993
  const hasContent = args.content !== undefined && args.content !== null;
1190
994
  const hasBase64 = args.base64 !== undefined && args.base64 !== null;
@@ -1232,12 +1036,6 @@ function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string):
1232
1036
  return hinted || "text/plain";
1233
1037
  }
1234
1038
 
1235
- function emitMessage(message: Message, created: boolean): void {
1236
- if (!created) return;
1237
- if (message.deliveryStatus === "queued") emitMessageQueued(message);
1238
- else emitNewMessage(message);
1239
- }
1240
-
1241
1039
  function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
1242
1040
  return TOOLS
1243
1041
  .filter((tool) => hasAnyScope(auth, tool.requiredScopes))
@@ -1264,12 +1062,6 @@ function jsonRpcError(id: JsonRpcId, code: number, message: string, data?: unkno
1264
1062
  return { jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } };
1265
1063
  }
1266
1064
 
1267
- function assertIntegrationTargetAllowed(auth: McpAuthContext, target?: string, channel?: string): void {
1268
- if (auth.integration && !isIntegrationAllowed(auth.integration, { target, channel })) {
1269
- throw new McpAuthError("integration token cannot target this message");
1270
- }
1271
- }
1272
-
1273
1065
  function assertComponentResourceAllowed(
1274
1066
  auth: McpAuthContext,
1275
1067
  check: Parameters<typeof isComponentAuthorizedFor>[1],
@@ -10,6 +10,8 @@ import { emitAgentStatus, emitNewMessage } from "../sse";
10
10
  import { getAgentProfile, getSpawnPolicy } from "../config-store";
11
11
  import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
12
12
  import { runnerRuntimeTokenEnv } from "../runtime-tokens";
13
+ import { authContextFromRequest } from "../services/auth-context";
14
+ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../services/shutdown-agent";
13
15
  import { type AgentCard, type SpawnApprovalMode } from "../types";
14
16
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
15
17
 
@@ -207,7 +209,40 @@ export const postAgentAction: Handler = async (req, params) => {
207
209
  if (!agent) return error("agent not found", 404);
208
210
  if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
209
211
 
210
- const orchestrator = (action === "restart" || action === "shutdown" || action === "resume") ? managedControlOrchestrator(agent) : null;
212
+ // Shutdown converges on the shutdownAgent service (epic #342, #347): one #221
213
+ // parent→child gate + canonical orchestrator resolution + payload, shared with the
214
+ // bus + MCP surfaces. The route still enforces the coarse `agent:write` scope +
215
+ // constraint-list; the service adds the fine parent→child gate.
216
+ if (action === "shutdown") {
217
+ const denied = authorizeRoute(req, {
218
+ scope: "agent:write",
219
+ resource: {
220
+ agentId: agent.id,
221
+ policyName: typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined,
222
+ spawnRequestId: typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined,
223
+ },
224
+ });
225
+ if (denied) return denied;
226
+ try {
227
+ const result = shutdownAgent(
228
+ {
229
+ agentId: agent.id,
230
+ requestedVia: "http",
231
+ requestedBy: "dashboard",
232
+ auditMetadata: { ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
233
+ },
234
+ authContextFromRequest(req),
235
+ );
236
+ return json({ ok: true, action, command: result.command }, 202);
237
+ } catch (e) {
238
+ if (e instanceof ShutdownAuthError) return error(e.message, 403);
239
+ if (e instanceof ShutdownTargetError) return error(e.message, 404);
240
+ throw e;
241
+ }
242
+ }
243
+
244
+ // "shutdown" is handled above by the shutdownAgent service and returns early.
245
+ const orchestrator = (action === "restart" || action === "resume") ? managedControlOrchestrator(agent) : null;
211
246
  const metaSessionName = typeof agent.meta?.sessionName === "string" ? agent.meta.sessionName : undefined;
212
247
  const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
213
248
  const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
@@ -240,8 +275,8 @@ export const postAgentAction: Handler = async (req, params) => {
240
275
  requestedAt: Date.now(),
241
276
  },
242
277
  });
243
- if (action === "shutdown" || action === "restart" || action === "resume") {
244
- const lifecycleAction = action === "shutdown" ? "shutting-down" : action === "resume" ? "resuming" : "restarting";
278
+ if (action === "restart" || action === "resume") {
279
+ const lifecycleAction = action === "resume" ? "resuming" : "restarting";
245
280
  markReady(agent.id, false);
246
281
  mergeAgentMeta(agent.id, { lifecycleAction, lifecycleActionAt: Date.now(), lifecycleCommandId: command.id });
247
282
  emitAgentStatus(agent.id);