agent-relay-server 0.32.1 → 0.32.2

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 (64) hide show
  1. package/docs/openapi.json +57 -127
  2. package/package.json +1 -1
  3. package/public/assets/{activity-C6nbfryG.js → activity-DT1JGHnp.js} +2 -2
  4. package/public/assets/{activity-C6nbfryG.js.map → activity-DT1JGHnp.js.map} +1 -1
  5. package/public/assets/{agent-profiles-FEITAgHs.js → agent-profiles-CrMemMkZ.js} +2 -2
  6. package/public/assets/{agent-profiles-FEITAgHs.js.map → agent-profiles-CrMemMkZ.js.map} +1 -1
  7. package/public/assets/{agents-D4S0yIbe.js → agents-Bl-rrgOy.js} +2 -2
  8. package/public/assets/{agents-D4S0yIbe.js.map → agents-Bl-rrgOy.js.map} +1 -1
  9. package/public/assets/{analytics-DM2g62T_.js → analytics-a663ak56.js} +2 -2
  10. package/public/assets/{analytics-DM2g62T_.js.map → analytics-a663ak56.js.map} +1 -1
  11. package/public/assets/{automation-3D2pQa1C.js → automation-CiaLThdO.js} +2 -2
  12. package/public/assets/{automation-3D2pQa1C.js.map → automation-CiaLThdO.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-Bi4IbkOZ.js → branch-state-badge-D4ur3m3_.js} +2 -2
  14. package/public/assets/{branch-state-badge-Bi4IbkOZ.js.map → branch-state-badge-D4ur3m3_.js.map} +1 -1
  15. package/public/assets/{channels-QNp7zmA_.js → channels-o9KLTHoK.js} +2 -2
  16. package/public/assets/{channels-QNp7zmA_.js.map → channels-o9KLTHoK.js.map} +1 -1
  17. package/public/assets/{chat-jeXt_SFs.js → chat-5hvHZcAe.js} +2 -2
  18. package/public/assets/{chat-jeXt_SFs.js.map → chat-5hvHZcAe.js.map} +1 -1
  19. package/public/assets/{connectors-BGJARDui.js → connectors-CdC806mA.js} +2 -2
  20. package/public/assets/{connectors-BGJARDui.js.map → connectors-CdC806mA.js.map} +1 -1
  21. package/public/assets/{formatted-body-impl-B7FgqkYL.js → formatted-body-impl-Ca74OAEH.js} +2 -2
  22. package/public/assets/{formatted-body-impl-B7FgqkYL.js.map → formatted-body-impl-Ca74OAEH.js.map} +1 -1
  23. package/public/assets/{index-2m9mT8kV.js → index-C_33ymaw.js} +6 -6
  24. package/public/assets/{index-2m9mT8kV.js.map → index-C_33ymaw.js.map} +1 -1
  25. package/public/assets/{integrations-CJm8-FcG.js → integrations-1nxMizDY.js} +2 -2
  26. package/public/assets/{integrations-CJm8-FcG.js.map → integrations-1nxMizDY.js.map} +1 -1
  27. package/public/assets/{maintenance-CBvZrVAG.js → maintenance-DiFNzNPN.js} +2 -2
  28. package/public/assets/{maintenance-CBvZrVAG.js.map → maintenance-DiFNzNPN.js.map} +1 -1
  29. package/public/assets/{managed-agents-Dcmm8YKt.js → managed-agents-Do3dKvfj.js} +2 -2
  30. package/public/assets/{managed-agents-Dcmm8YKt.js.map → managed-agents-Do3dKvfj.js.map} +1 -1
  31. package/public/assets/{markdown-preview-impl-7xjqdiEu.js → markdown-preview-impl-CLA0J255.js} +2 -2
  32. package/public/assets/{markdown-preview-impl-7xjqdiEu.js.map → markdown-preview-impl-CLA0J255.js.map} +1 -1
  33. package/public/assets/{memory-BmGNW61h.js → memory-IjwqFzBd.js} +2 -2
  34. package/public/assets/{memory-BmGNW61h.js.map → memory-IjwqFzBd.js.map} +1 -1
  35. package/public/assets/{messages-BvMMhoy-.js → messages-DjvWqHyn.js} +2 -2
  36. package/public/assets/{messages-BvMMhoy-.js.map → messages-DjvWqHyn.js.map} +1 -1
  37. package/public/assets/{orchestrators-DsstaupT.js → orchestrators-D2IqDxDT.js} +2 -2
  38. package/public/assets/{orchestrators-DsstaupT.js.map → orchestrators-D2IqDxDT.js.map} +1 -1
  39. package/public/assets/{overview-kK6PTce3.js → overview-DKC3TbAh.js} +2 -2
  40. package/public/assets/{overview-kK6PTce3.js.map → overview-DKC3TbAh.js.map} +1 -1
  41. package/public/assets/{pairs-BEFvTW6X.js → pairs-WpKCPE1n.js} +2 -2
  42. package/public/assets/{pairs-BEFvTW6X.js.map → pairs-WpKCPE1n.js.map} +1 -1
  43. package/public/assets/{security-Dc5wZwv0.js → security-BF7ZtPQe.js} +2 -2
  44. package/public/assets/{security-Dc5wZwv0.js.map → security-BF7ZtPQe.js.map} +1 -1
  45. package/public/assets/{settings-CEtJrORa.js → settings-CQnjrTa-.js} +2 -2
  46. package/public/assets/{settings-CEtJrORa.js.map → settings-CQnjrTa-.js.map} +1 -1
  47. package/public/assets/{store-DkmReBlH.js → store-C9VcSo05.js} +2 -2
  48. package/public/assets/{store-DkmReBlH.js.map → store-C9VcSo05.js.map} +1 -1
  49. package/public/assets/{tasks-pQKtxqeV.js → tasks-CbN_GSSb.js} +2 -2
  50. package/public/assets/{tasks-pQKtxqeV.js.map → tasks-CbN_GSSb.js.map} +1 -1
  51. package/public/assets/{terminal-viewer-impl-Cc769mYy.js → terminal-viewer-impl-BJRohThT.js} +2 -2
  52. package/public/assets/{terminal-viewer-impl-Cc769mYy.js.map → terminal-viewer-impl-BJRohThT.js.map} +1 -1
  53. package/public/assets/{work-queue-DjAanr02.js → work-queue-C5xLBLmm.js} +2 -2
  54. package/public/assets/{work-queue-DjAanr02.js.map → work-queue-C5xLBLmm.js.map} +1 -1
  55. package/public/assets/{workspaces-DLBNyR4k.js → workspaces-D91H3wDX.js} +2 -2
  56. package/public/assets/{workspaces-DLBNyR4k.js.map → workspaces-D91H3wDX.js.map} +1 -1
  57. package/public/index.html +2 -2
  58. package/scripts/orchestrator-spawn-smoke.ts +2 -1
  59. package/src/automations.ts +2 -4
  60. package/src/managed-policy.ts +2 -4
  61. package/src/mcp.ts +3 -3
  62. package/src/routes.ts +69 -139
  63. package/src/runtime-tokens.ts +17 -8
  64. package/src/security.ts +0 -2
package/src/routes.ts CHANGED
@@ -124,7 +124,6 @@ import {
124
124
  setAgentProfile,
125
125
  setConfig,
126
126
  setStewardConfig,
127
- spawnGrantForProfile,
128
127
  upsertManagedAgentState,
129
128
  updateManagedAgentState,
130
129
  } from "./config-store";
@@ -215,7 +214,7 @@ import { rankRouteCandidates, type RouteAdvisorInput } from "./context-router";
215
214
  import { MemoryBrokerContractError } from "./memory-broker-contract";
216
215
  import { assertMemoryCreateAllowed, assertMemoryUpdateAllowed } from "./memory-security";
217
216
  import { captureTaskResultMemory, clearActiveMemories, injectAlwaysReloadMemories, injectMemoryContext, injectMemoryForMessageDelivery, injectMemoryForTaskClaim, memoryBroker, memoryBrokerConfig } from "./memory-service";
218
- import { postMcp } from "./mcp";
217
+ import { postMcp, selectSpawnOrchestrator, McpNotFoundError } from "./mcp";
219
218
  import { readFileSync } from "node:fs";
220
219
  import { resolve } from "node:path";
221
220
  import { isPathWithinBase } from "./utils";
@@ -2281,6 +2280,7 @@ function restartSpawnParamsForAgent(
2281
2280
  policyName,
2282
2281
  spawnRequestId: requestId,
2283
2282
  createdBy: requestedBy,
2283
+ profile: profileName || undefined,
2284
2284
  }),
2285
2285
  requestedBy,
2286
2286
  requestedAt: Date.now(),
@@ -2585,76 +2585,95 @@ const deleteAgentTerminalSession: Handler = async (req, params) => {
2585
2585
  return proxyOrchestratorDelete(req, orchestrator.id, `/api/terminal-guests/${encodeURIComponent(session)}`);
2586
2586
  };
2587
2587
 
2588
+ // Canonical spawn endpoint. The agent is the resource being created; the host is a parameter
2589
+ // (`orchestratorId`, defaulting to where the cwd lives), NOT a path segment — which is why this
2590
+ // supersedes the old `/api/orchestrators/:id/spawn`. ONE spawn path, so the spawn grant (resolved
2591
+ // from the profile inside the token mint) can't be threaded on some routes and forgotten on others.
2588
2592
  const postAgentSpawn: Handler = async (req) => {
2589
2593
  const parsed = await parseBody<unknown>(req);
2590
2594
  if (!parsed.ok) return error(parsed.error, parsed.status);
2591
2595
  try {
2592
2596
  if (!isRecord(parsed.body)) return error("provider required");
2593
- const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
2597
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider | undefined;
2594
2598
  if (!provider) return error("provider required");
2595
- const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
2596
- const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
2599
+ const orchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 });
2597
2600
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
2601
+ // Resolves an explicit host, else the host whose baseDir contains cwd, else the lone candidate
2602
+ // (the same resolver the MCP spawn tool uses — one home for host selection).
2603
+ const orch = selectSpawnOrchestrator(provider, orchestratorId, cwd);
2604
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
2605
+ return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
2606
+ }
2607
+ const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
2608
+ if (selection instanceof Response) return selection;
2609
+ const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
2598
2610
  const label = cleanString(parsed.body.label, "label", { max: 120 });
2599
2611
  const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
2600
2612
  const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
2601
- const grant = spawnGrantForProfile(profile);
2602
-
2603
- const orchestrators = listOrchestrators().filter(
2604
- (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
2605
- );
2606
- const orch = orchestrators[0];
2607
- if (!orch) return error("no orchestrator available for provider: " + provider);
2608
- if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
2609
- return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
2610
- }
2611
- const requestId = spawnRequestId();
2612
- const denied = authorizeRoute(req, {
2613
- scope: "agent:write",
2614
- resource: { orchestratorId: orch.id, cwd: cwd || orch.baseDir },
2615
- });
2616
- if (denied) return denied;
2613
+ const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
2614
+ const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
2615
+ const tags = cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) ?? [];
2616
+ const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }) ?? [];
2617
+ const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }) ?? [];
2618
+ const agentProfile = profile ? getAgentProfile(profile)?.value : undefined;
2619
+ if (profile && !agentProfile) return error("agent profile not found", 404);
2620
+ const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
2621
+ const requestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }) ?? spawnRequestId();
2622
+ const denied = authorizeRoute(req, {
2623
+ scope: "agent:write",
2624
+ resource: { orchestratorId: orch.id, cwd: cwd || orch.baseDir, policyName, spawnRequestId: requestId },
2625
+ });
2626
+ if (denied) return denied;
2617
2627
  const command = createCommand({
2618
- type: "agent.spawn",
2619
- source: "system",
2620
- target: orch.agentId,
2621
- correlationId: requestId,
2622
- params: buildSpawnCommand({
2623
- provider,
2624
- modelParams: selection,
2625
- cwd: cwd || orch.baseDir,
2626
- workspaceMode,
2627
- label,
2628
- approvalMode,
2629
- profile: profile || undefined,
2630
- spawnRequestId: requestId,
2631
- requestedBy: "dashboard",
2632
- requestedAt: Date.now(),
2633
- env: runnerRuntimeTokenEnv({
2634
- orchestratorId: orch.id,
2635
- cwd: cwd || orch.baseDir,
2636
- provider,
2637
- label,
2638
- spawnRequestId: requestId,
2639
- createdBy: "dashboard",
2640
- canSpawn: grant.canSpawn,
2641
- maxSpawnedAgents: grant.maxSpawnedAgents,
2642
- }),
2643
- }),
2644
- });
2628
+ type: "agent.spawn",
2629
+ source: "system",
2630
+ target: orch.agentId,
2631
+ correlationId: requestId,
2632
+ params: buildSpawnCommand({
2633
+ provider,
2634
+ modelParams: selection,
2635
+ cwd: cwd || orch.baseDir,
2636
+ workspaceMode,
2637
+ label,
2638
+ tags,
2639
+ capabilities,
2640
+ approvalMode,
2641
+ permissionMode: approvalMode,
2642
+ profile: profile || undefined,
2643
+ agentProfile,
2644
+ providerArgs,
2645
+ prompt,
2646
+ systemPromptAppend,
2647
+ policyName,
2648
+ spawnRequestId: requestId,
2649
+ env: runnerRuntimeTokenEnv({
2650
+ orchestratorId: orch.id,
2651
+ cwd: cwd || orch.baseDir,
2652
+ provider,
2653
+ label,
2654
+ policyName,
2655
+ spawnRequestId: requestId,
2656
+ createdBy: "dashboard",
2657
+ profile: profile || undefined,
2658
+ }),
2659
+ requestedBy: "dashboard",
2660
+ requestedAt: Date.now(),
2661
+ }),
2662
+ });
2645
2663
  emitCommand(command);
2646
2664
  auditEvent({
2647
- clientId: "server-agent-spawn-" + provider + "-" + Date.now(),
2665
+ clientId: "server-agent-spawn-" + provider + "-" + command.id,
2648
2666
  kind: "state",
2649
2667
  title: `${provider} agent spawn requested (via ${orch.id})`,
2650
2668
  body: cwd || orch.baseDir,
2651
2669
  meta: orch.id,
2652
2670
  icon: "ti-plus",
2653
2671
  view: "agents",
2654
- metadata: { provider, orchestratorId: orch.id, approvalMode, workspaceMode, commandId: command.id, ...authAuditMetadata(req) },
2672
+ metadata: { provider, orchestratorId: orch.id, approvalMode, workspaceMode, label, commandId: command.id, ...authAuditMetadata(req) },
2655
2673
  });
2656
2674
  return json({ ok: true, orchestratorId: orch.id, provider, command }, 202);
2657
2675
  } catch (e) {
2676
+ if (e instanceof McpNotFoundError) return error(e.message, 404);
2658
2677
  if (e instanceof ValidationError) return error(e.message, 400);
2659
2678
  if (e instanceof Error) return error(e.message, 400);
2660
2679
  throw e;
@@ -3515,94 +3534,6 @@ function cleanSafeNumber(value: unknown): number | undefined {
3515
3534
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
3516
3535
  }
3517
3536
 
3518
- const postOrchestratorSpawn: Handler = async (req, params) => {
3519
- const parsed = await parseBody<unknown>(req);
3520
- if (!parsed.ok) return error(parsed.error, parsed.status);
3521
- try {
3522
- const orch = getOrchestrator(params.id!);
3523
- if (!orch) return error("orchestrator not found", 404);
3524
- if (orch.status !== "online") return error("orchestrator is offline", 409);
3525
-
3526
- if (!isRecord(parsed.body)) return error("body required");
3527
- const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
3528
- const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
3529
- if (selection instanceof Response) return selection;
3530
- const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
3531
- if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
3532
- return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
3533
- }
3534
- const label = cleanString(parsed.body.label, "label", { max: 120 });
3535
- const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
3536
- const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
3537
- const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
3538
- const tags = cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) ?? [];
3539
- const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }) ?? [];
3540
- const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }) ?? [];
3541
- const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
3542
- const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
3543
- const agentProfile = profile ? getAgentProfile(profile)?.value : undefined;
3544
- if (profile && !agentProfile) return error("agent profile not found", 404);
3545
- const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
3546
- const requestId = cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }) ?? spawnRequestId();
3547
- const denied = authorizeRoute(req, {
3548
- scope: "agent:write",
3549
- resource: { orchestratorId: orch.id, cwd: cwd || orch.baseDir, policyName, spawnRequestId: requestId },
3550
- });
3551
- if (denied) return denied;
3552
-
3553
- const command = createCommand({
3554
- type: "agent.spawn",
3555
- source: "system",
3556
- target: orch.agentId,
3557
- correlationId: requestId,
3558
- params: buildSpawnCommand({
3559
- provider,
3560
- modelParams: { model: selection.model, providerModel: selection.providerModel, effort: selection.effort },
3561
- cwd: cwd || orch.baseDir,
3562
- workspaceMode,
3563
- label,
3564
- tags,
3565
- capabilities,
3566
- approvalMode,
3567
- permissionMode: approvalMode,
3568
- profile,
3569
- agentProfile,
3570
- providerArgs,
3571
- prompt,
3572
- systemPromptAppend,
3573
- policyName,
3574
- spawnRequestId: requestId,
3575
- env: runnerRuntimeTokenEnv({
3576
- orchestratorId: orch.id,
3577
- cwd: cwd || orch.baseDir,
3578
- provider,
3579
- label,
3580
- policyName,
3581
- spawnRequestId: requestId,
3582
- createdBy: "dashboard",
3583
- }),
3584
- requestedBy: "dashboard",
3585
- requestedAt: Date.now(),
3586
- }),
3587
- });
3588
- emitCommand(command);
3589
- auditEvent({
3590
- clientId: "server-orchestrator-spawn-" + orch.id + "-" + command.id,
3591
- kind: "state",
3592
- title: `Spawn ${provider} agent requested`,
3593
- body: cwd || orch.baseDir,
3594
- meta: orch.id,
3595
- icon: "ti-plus",
3596
- view: "orchestrators",
3597
- metadata: { orchestratorId: orch.id, provider, approvalMode, workspaceMode, label, commandId: command.id, ...authAuditMetadata(req) },
3598
- });
3599
- return json({ ok: true, orchestratorId: orch.id, command }, 202);
3600
- } catch (e) {
3601
- if (e instanceof ValidationError) return error(e.message, 400);
3602
- throw e;
3603
- }
3604
- };
3605
-
3606
3537
  const ORCH_UPGRADE_DEADLINE_MS = 5 * 60_000;
3607
3538
  const ORCH_UPGRADE_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
3608
3539
  const VALID_ORCH_UPGRADE_PROVIDERS = ["auto", "all", "codex", "claude", "orchestrator"] as const;
@@ -6698,7 +6629,6 @@ const routes: Route[] = [
6698
6629
  route("GET", "/api/orchestrators/:id", getOrchestratorById),
6699
6630
  route("POST", "/api/orchestrators/:id/heartbeat", postOrchestratorHeartbeat),
6700
6631
  route("PATCH", "/api/orchestrators/:id/agents", patchOrchestratorAgents),
6701
- route("POST", "/api/orchestrators/:id/spawn", postOrchestratorSpawn),
6702
6632
  route("POST", "/api/orchestrators/:id/runner-token", postOrchestratorRunnerToken),
6703
6633
  route("POST", "/api/orchestrators/:id/actions", postOrchestratorAction),
6704
6634
  route("GET", "/api/orchestrators/:id/directories", getOrchestratorDirectories),
@@ -1,5 +1,6 @@
1
1
  import { createToken, getTokenProfile, revokeToken } from "./token-db";
2
2
  import { verifyComponentTokenAllowExpired } from "./security";
3
+ import { spawnGrantForProfile } from "./config-store";
3
4
  import type { TokenRecord } from "./types";
4
5
 
5
6
  // Scopes that turn an agent's runtime token into a spawn-capable one (#221). Appended to the
@@ -89,13 +90,19 @@ export function issueRunnerRuntimeToken(input: {
89
90
  policyName?: string;
90
91
  spawnRequestId?: string;
91
92
  createdBy?: string;
92
- /** Grant the spawn/shutdown scopes (resolved from the agent's profile maxSpawnedAgents>0). */
93
- canSpawn?: boolean;
94
- /** Live-children quota baked into the token for the runtime spawn check. */
95
- maxSpawnedAgents?: number;
93
+ /**
94
+ * Agent profile name. The spawn grant (the `command:spawn`/`command:shutdown` scope + the
95
+ * live-children quota) is resolved from this profile's `maxSpawnedAgents` HERE, in the single
96
+ * mint, so capability⇒tool holds for EVERY spawn path. Callers pass the profile they already
97
+ * have; they cannot forget to thread the grant (the recurring bug this centralization kills).
98
+ */
99
+ profile?: string;
100
+ /** Agent-requested spawn → forces a non-spawn-capable child regardless of profile (no grandchildren). */
101
+ agentInitiated?: boolean;
96
102
  /** Parent agent id — stamped so the child registers with an authoritative `spawnedBy`. */
97
103
  spawnedBy?: string;
98
104
  }): RuntimeTokenResult {
105
+ const grant = spawnGrantForProfile(input.profile, input.agentInitiated);
99
106
  const subject = input.policyName
100
107
  ? `runner:policy:${input.policyName}`
101
108
  : input.spawnRequestId
@@ -105,13 +112,13 @@ export function issueRunnerRuntimeToken(input: {
105
112
  profileId: "provider-agent",
106
113
  sub: subject,
107
114
  role: "provider",
108
- scope: runnerScopeWithSpawn(input.canSpawn ?? false),
115
+ scope: runnerScopeWithSpawn(grant.canSpawn),
109
116
  constraints: {
110
117
  orchestrators: [input.orchestratorId],
111
118
  cwdPrefixes: [input.cwd],
112
119
  ...(input.policyName ? { policies: [input.policyName] } : {}),
113
120
  ...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
114
- ...(input.canSpawn && input.maxSpawnedAgents ? { maxSpawnedAgents: input.maxSpawnedAgents } : {}),
121
+ ...(grant.canSpawn && grant.maxSpawnedAgents ? { maxSpawnedAgents: grant.maxSpawnedAgents } : {}),
115
122
  ...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
116
123
  },
117
124
  createdBy: input.createdBy ?? "runtime",
@@ -226,8 +233,10 @@ export function runnerRuntimeTokenEnv(input: {
226
233
  policyName?: string;
227
234
  spawnRequestId?: string;
228
235
  createdBy?: string;
229
- canSpawn?: boolean;
230
- maxSpawnedAgents?: number;
236
+ /** Agent profile name — spawn grant is resolved from it in the mint (see issueRunnerRuntimeToken). */
237
+ profile?: string;
238
+ /** Agent-requested spawn → forces a non-spawn-capable child (no grandchildren). */
239
+ agentInitiated?: boolean;
231
240
  spawnedBy?: string;
232
241
  }): Record<string, string> {
233
242
  const issued = issueRunnerRuntimeToken(input);
package/src/security.ts CHANGED
@@ -170,7 +170,6 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
170
170
  if (pathname === "/api/orchestrators/bootstrap") return "token:write";
171
171
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
172
172
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
173
- if (pathname.match(/^\/api\/orchestrators\/[^/]+\/spawn$/)) return "agent:write";
174
173
  if (pathname.startsWith("/api/artifacts")) {
175
174
  if (method === "GET" || method === "HEAD") return "artifact:read";
176
175
  if (method === "DELETE") return "artifact:admin";
@@ -242,7 +241,6 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
242
241
  if (pathname === "/api/mcp") return "mcp:use";
243
242
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
244
243
  if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:attach";
245
- if (pathname.match(/^\/api\/orchestrators\/[^/]+\/spawn$/)) return "agent:write";
246
244
  if (pathname.startsWith("/api/commands")) {
247
245
  if (method === "GET") return "command:read";
248
246
  return "command:write";