agent-relay-server 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  getAgentTimeline,
6
6
  listAgents,
7
7
  listPendingReplyObligations,
8
- findAgentsByCapability,
8
+ searchAgents,
9
9
  setStatus,
10
10
  setLabel,
11
11
  setTags,
@@ -124,6 +124,7 @@ import {
124
124
  setAgentProfile,
125
125
  setConfig,
126
126
  setStewardConfig,
127
+ spawnGrantForProfile,
127
128
  upsertManagedAgentState,
128
129
  updateManagedAgentState,
129
130
  } from "./config-store";
@@ -157,6 +158,7 @@ import type { ActivityEventInput, ActivityKind, AgentCard, AgentKind, AgentProfi
157
158
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, VERSION, type IntegrationTokenConfig } from "./config";
158
159
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "./contracts";
159
160
  import { listHostDirectories } from "./agent-spawn";
161
+ import { planSend } from "./agent-ref";
160
162
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
161
163
  import type { ProviderConfig } from "../runner/src/adapter";
162
164
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
@@ -166,6 +168,14 @@ import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./
166
168
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
167
169
  import { requestWorkspaceMerge } from "./workspace-merge";
168
170
  import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
171
+ import {
172
+ applyWorkspaceAction,
173
+ buildWorkspaceCleanupCommand,
174
+ buildWorkspaceDepsRefreshCommand,
175
+ waitForWorkspaceStatus,
176
+ WORKSPACE_ACTIONS,
177
+ } from "./workspace-actions";
178
+ import { describeWorkspacePhase, landReceipt, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
169
179
  import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
170
180
  import {
171
181
  getComponentAuth,
@@ -1240,6 +1250,12 @@ const postAgent: Handler = async (req) => {
1240
1250
  if (!parsed.ok) return error(parsed.error, parsed.status);
1241
1251
  try {
1242
1252
  const input = normalizeAgentInput(parsed.body);
1253
+ // Lineage is authoritative from the registering token's signed constraints — never the
1254
+ // client-sent body (a child can't forge its own parent). Set by relay at spawn for
1255
+ // agent-initiated spawns (`spawnedBy`) and the delegating-component path (`parentAgents`).
1256
+ const constraints = getComponentAuth(req)?.constraints;
1257
+ const lineage = constraints?.spawnedBy ?? constraints?.parentAgents?.[0];
1258
+ if (lineage) input.spawnedBy = lineage;
1243
1259
  const existing = getAgent(input.id);
1244
1260
  const agent = upsertAgent(input);
1245
1261
  const policyName = metaString(agent.meta, "policyName");
@@ -1305,8 +1321,8 @@ const findAgents: Handler = (req) => {
1305
1321
  const url = new URL(req.url);
1306
1322
  const capability = url.searchParams.get("capability");
1307
1323
  if (!capability) return error("capability query param required");
1308
- const onlineOnly = url.searchParams.get("all") !== "true";
1309
- return json(findAgentsByCapability(capability, onlineOnly));
1324
+ const includeAll = url.searchParams.get("all") === "true";
1325
+ return json(searchAgents({ capability, status: includeAll ? "all" : "running" }));
1310
1326
  };
1311
1327
 
1312
1328
  const postRouteAdvice: Handler = async (req) => {
@@ -2538,6 +2554,8 @@ const postAgentSpawn: Handler = async (req) => {
2538
2554
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
2539
2555
  const label = cleanString(parsed.body.label, "label", { max: 120 });
2540
2556
  const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
2557
+ const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
2558
+ const grant = spawnGrantForProfile(profile);
2541
2559
 
2542
2560
  const orchestrators = listOrchestrators().filter(
2543
2561
  (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
@@ -2565,6 +2583,7 @@ const postAgentSpawn: Handler = async (req) => {
2565
2583
  workspaceMode,
2566
2584
  label,
2567
2585
  approvalMode,
2586
+ profile: profile || undefined,
2568
2587
  spawnRequestId: requestId,
2569
2588
  requestedBy: "dashboard",
2570
2589
  requestedAt: Date.now(),
@@ -2575,6 +2594,8 @@ const postAgentSpawn: Handler = async (req) => {
2575
2594
  label,
2576
2595
  spawnRequestId: requestId,
2577
2596
  createdBy: "dashboard",
2597
+ canSpawn: grant.canSpawn,
2598
+ maxSpawnedAgents: grant.maxSpawnedAgents,
2578
2599
  }),
2579
2600
  }),
2580
2601
  });
@@ -3855,8 +3876,6 @@ const getWorkspaceDiff: Handler = (req, params) => {
3855
3876
  return proxyWorkspaceHostGet(params.id!, "/api/workspace/diff", patch === "0" ? { patch: "0" } : undefined);
3856
3877
  };
3857
3878
 
3858
- const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
3859
-
3860
3879
  // Worktrees found on disk (agent/* branches) with no live workspace row — left
3861
3880
  // behind by crashes or failed cleanups. Probes each known repo's owning host
3862
3881
  // and subtracts active DB rows. Reclaim them via POST .../orphans/reclaim.
@@ -3935,59 +3954,8 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
3935
3954
  }
3936
3955
  };
3937
3956
 
3938
- // Build a `workspace.cleanup` command for a worktree's owning orchestrator. Shared
3939
- // by the manual cleanup action and the cleanup-stale sweep (#208) so both resolve
3940
- // the owner and shape params identically. Queues (no TTL) when the owner is offline;
3941
- // hard-fails only when no orchestrator owns the path (DELETE is then the escape).
3942
- function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
3943
- const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
3944
- const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
3945
- if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
3946
- const command = createCommand({
3947
- type: "workspace.cleanup",
3948
- source: "system",
3949
- target: owner.agentId,
3950
- correlationId: workspace.id,
3951
- params: {
3952
- action: "cleanup",
3953
- workspaceId: workspace.id,
3954
- repoRoot: workspace.repoRoot,
3955
- worktreePath: workspace.worktreePath,
3956
- branch: workspace.branch,
3957
- requestedBy,
3958
- requestedAt: Date.now(),
3959
- deleteBranch: true,
3960
- queued: owner.status !== "online",
3961
- },
3962
- });
3963
- return { ok: true, command };
3964
- }
3965
-
3966
- // Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
3967
- // (issue #51). Re-provisions deps the shared symlinked node_modules has gone stale
3968
- // on, or — checkOnly — just reports staleness. Same owner-resolution as cleanup.
3969
- function buildWorkspaceDepsRefreshCommand(workspace: WorkspaceRecord, requestedBy: string, checkOnly: boolean): { ok: true; command: Command } | { ok: false; status: number; error: string } {
3970
- const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
3971
- const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
3972
- if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
3973
- const command = createCommand({
3974
- type: "workspace.deps-refresh",
3975
- source: "system",
3976
- target: owner.agentId,
3977
- correlationId: workspace.id,
3978
- params: {
3979
- action: "deps-refresh",
3980
- workspaceId: workspace.id,
3981
- repoRoot: workspace.repoRoot,
3982
- worktreePath: workspace.worktreePath,
3983
- checkOnly,
3984
- requestedBy,
3985
- requestedAt: Date.now(),
3986
- queued: owner.status !== "online",
3987
- },
3988
- });
3989
- return { ok: true, command };
3990
- }
3957
+ // buildWorkspaceCleanupCommand / buildWorkspaceDepsRefreshCommand now live in
3958
+ // ./workspace-actions (shared with the relay_workspace_* MCP tools) imported above.
3991
3959
 
3992
3960
  // Fetch + parse a workspace's live git state from its owning host, or report why
3993
3961
  // it's unavailable. Thin typed wrapper over the same host route the proxy uses.
@@ -4025,6 +3993,13 @@ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnli
4025
3993
  if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
4026
3994
  if (ahead === 0 || landed) {
4027
3995
  if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
3996
+ // Owner active but the workspace is awaiting attention with nothing to land (#230):
3997
+ // landing it is a safe no-op that resolves it to terminal `merged`, clearing the
3998
+ // queue (the host keeps a live owner's worktree). Otherwise there's genuinely
3999
+ // nothing to do.
4000
+ if (workspace.status === "review_requested" || workspace.status === "conflict") {
4001
+ return { action: "merge", confidence: "high", reason: landed ? "work already landed; resolve the no-op" : "nothing to merge; resolve the no-op" };
4002
+ }
4028
4003
  return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
4029
4004
  }
4030
4005
  if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
@@ -4127,129 +4102,64 @@ const postWorkspaceAction: Handler = async (req, params) => {
4127
4102
  if (!isRecord(parsed.body)) return error("body required");
4128
4103
  const workspace = getWorkspace(params.id!);
4129
4104
  if (!workspace) return error("workspace not found", 404);
4130
- const action = optionalEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim", "deps-refresh"] as const);
4105
+ const action = optionalEnum(parsed.body.action, "action", WORKSPACE_ACTIONS);
4131
4106
  if (!action) return error("action required", 400);
4132
4107
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
4133
- const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
4134
- const metadata = cleanMeta(parsed.body.metadata) ?? {};
4135
4108
  const requiresCommand = action === "cleanup" || action === "merge";
4136
- // Shared-mode rows are occupancy markers with no worktree — there is nothing
4137
- // on disk to merge or clean. Reject host commands against them up front.
4109
+ // Shared-mode rows are occupancy markers with no worktree — reject host commands
4110
+ // up front, before authz, preserving the original 422-before-auth ordering.
4138
4111
  if (requiresCommand && (workspace.mode !== "isolated" || !workspace.worktreePath)) {
4139
4112
  return error(`workspace ${workspace.id} has no worktree to ${action}`, 422);
4140
4113
  }
4141
4114
  const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
4142
4115
  if (denied) return denied;
4143
- if (action === "status") return json(workspace);
4144
- // Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
4145
- // so deterministic landing can't race a steward mid-validation. No status change.
4146
- if (action === "claim" || action === "release-claim") {
4147
- const release = action === "release-claim";
4148
- const purpose = cleanString(parsed.body.purpose, "purpose", { max: 120 });
4149
- const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", purpose));
4150
- if (!updated) return error("workspace not found", 404);
4151
- auditEvent({
4152
- clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
4153
- kind: "state",
4154
- title: release ? "Workspace claim released" : "Workspace claimed",
4155
- body: detail ?? purpose ?? workspace.worktreePath,
4156
- meta: workspace.branch ?? workspace.id,
4157
- icon: "ti-lock",
4158
- view: "orchestrators",
4159
- agentId,
4160
- metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, ...authAuditMetadata(req) },
4161
- });
4162
- return json({ workspace: updated, claim: workspaceActiveClaim(updated) });
4163
- }
4164
- // Base merges go through the shared helper (lease + command + bind), the same
4165
- // path the auto-merge job uses, so both serialize per repo (issue #157).
4166
- if (action === "merge") {
4167
- const strategy = optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4168
- const result = requestWorkspaceMerge(workspace, {
4169
- requestedBy: agentId ?? "dashboard",
4170
- strategy,
4171
- deleteBranch: parsed.body.deleteBranch !== false,
4172
- prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4173
- prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4174
- metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
4175
- });
4176
- if (!result.ok) return error(result.error, result.status);
4177
- emitCommand(result.command);
4178
- auditEvent({
4179
- clientId: `workspace-merge-${workspace.id}-${Date.now()}`,
4180
- kind: "state",
4181
- title: "Workspace merge",
4182
- body: detail ?? workspace.worktreePath,
4183
- meta: workspace.branch ?? workspace.id,
4184
- icon: "ti-git-merge",
4185
- view: "orchestrators",
4186
- agentId,
4187
- metadata: { action: "merge", workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: result.workspace.status, commandId: result.command.id, ...authAuditMetadata(req) },
4188
- });
4189
- return json({ workspace: result.workspace, command: result.command }, 202);
4190
- }
4191
- // Deps refresh (#51): self-service for the owning agent — re-provision deps the
4192
- // shared symlinked node_modules has gone stale on. Emits a host command but is
4193
- // authorized at agent:write (it only touches the agent's own worktree), so the
4194
- // agent can run it themselves the moment typecheck reports a missing module.
4195
- if (action === "deps-refresh") {
4196
- if (workspace.mode !== "isolated" || !workspace.worktreePath) {
4197
- return error(`workspace ${workspace.id} has no isolated worktree to refresh`, 422);
4116
+
4117
+ // status: optionally long-poll until the workspace transitions after a `ready`,
4118
+ // block until the auto-merge job lands it (→ merged/recycled-to-active) or it
4119
+ // stalls into conflict/review_requested, instead of the caller busy-polling.
4120
+ if (action === "status") {
4121
+ if (parsed.body.wait === true) {
4122
+ const timeoutSeconds = typeof parsed.body.timeoutSeconds === "number" && parsed.body.timeoutSeconds > 0 ? parsed.body.timeoutSeconds : undefined;
4123
+ const waited = await waitForWorkspaceStatus(workspace.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
4124
+ if (!waited.workspace) return error("workspace not found", 404);
4125
+ const landed = waited.transitioned ? landReceipt(waited.fromStatus, waited.workspace) : null;
4126
+ // Mirror the relay_workspace_status MCP tool: bare record (legacy fields)
4127
+ // plus the directive projection + land receipt so the CLI `--wait` and any
4128
+ // HTTP caller get the same legible answer.
4129
+ return json({
4130
+ workspace: waited.workspace,
4131
+ guidance: describeWorkspacePhase(waited.workspace),
4132
+ ...(landed ? { landed } : {}),
4133
+ fromStatus: waited.fromStatus,
4134
+ transitioned: waited.transitioned,
4135
+ timedOut: waited.timedOut,
4136
+ });
4198
4137
  }
4199
- const checkOnly = parsed.body.checkOnly === true;
4200
- const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
4201
- if (!built.ok) return error(built.error, built.status);
4202
- emitCommand(built.command);
4203
- auditEvent({
4204
- clientId: `workspace-deps-refresh-${workspace.id}-${Date.now()}`,
4205
- kind: "state",
4206
- title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
4207
- body: detail ?? workspace.worktreePath,
4208
- meta: workspace.branch ?? workspace.id,
4209
- icon: "ti-package",
4210
- view: "orchestrators",
4211
- agentId,
4212
- metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, checkOnly, commandId: built.command.id, ...authAuditMetadata(req) },
4213
- });
4214
- return json({ workspace, command: built.command }, 202);
4215
- }
4216
- const statusByAction: Record<string, WorkspaceStatus | undefined> = {
4217
- status: undefined,
4218
- ready: "ready",
4219
- "conflict-found": "conflict",
4220
- "request-review": "review_requested",
4221
- "merge-plan": "merge_planned",
4222
- abandon: "abandoned",
4223
- cleanup: "cleanup_requested",
4224
- };
4225
- const updated = updateWorkspaceStatus(workspace.id, statusByAction[action]!, {
4226
- ...metadata,
4227
- ...(detail ? { detail } : {}),
4228
- ...(agentId ? { updatedByAgentId: agentId } : {}),
4229
- lastWorkspaceAction: action,
4230
- lastWorkspaceActionAt: Date.now(),
4231
- });
4232
- if (!updated) return error("workspace not found", 404);
4233
- let command: Command | undefined;
4234
- if (requiresCommand) {
4235
- // Only `cleanup` reaches here — `merge` returned early via the shared helper.
4236
- const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
4237
- if (!built.ok) return error(built.error, built.status);
4238
- command = built.command;
4239
- emitCommand(command);
4138
+ return json(workspace);
4240
4139
  }
4241
- auditEvent({
4242
- clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
4243
- kind: "state",
4244
- title: `Workspace ${action}`,
4245
- body: detail ?? workspace.worktreePath,
4246
- meta: workspace.branch ?? workspace.id,
4247
- icon: action === "cleanup" ? "ti-trash" : action === "conflict-found" ? "ti-alert-triangle" : "ti-git-branch",
4248
- view: "orchestrators",
4140
+
4141
+ // Everything else delegates to the shared core (one home, shared with the
4142
+ // relay_workspace_* MCP tools); the core self-audits and returns any command to
4143
+ // emit, mirroring requestWorkspaceMerge's "caller emits" contract.
4144
+ const result = applyWorkspaceAction(workspace, {
4145
+ action,
4249
4146
  agentId,
4250
- metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, status: updated.status, commandId: command?.id, ...authAuditMetadata(req) },
4147
+ detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
4148
+ metadata: cleanMeta(parsed.body.metadata) ?? {},
4149
+ strategy: optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy,
4150
+ deleteBranch: typeof parsed.body.deleteBranch === "boolean" ? parsed.body.deleteBranch : undefined,
4151
+ prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
4152
+ prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
4153
+ purpose: cleanString(parsed.body.purpose, "purpose", { max: 120 }),
4154
+ checkOnly: parsed.body.checkOnly === true,
4155
+ auditMetadata: authAuditMetadata(req),
4251
4156
  });
4252
- return json({ workspace: updated, command }, requiresCommand ? 202 : 200);
4157
+ if (!result.ok) return error(result.error, result.httpStatus);
4158
+ if (result.command) emitCommand(result.command);
4159
+ const payload: Record<string, unknown> = { workspace: result.workspace };
4160
+ if (result.command) payload.command = result.command;
4161
+ if (result.claim !== undefined) payload.claim = result.claim;
4162
+ return json(payload, result.httpStatus);
4253
4163
  } catch (e) {
4254
4164
  if (e instanceof ValidationError) return error(e.message, 400);
4255
4165
  throw e;
@@ -5273,11 +5183,25 @@ const postMessage: Handler = async (req) => {
5273
5183
  resource: { target: input.to, channel: input.channel, agentId: input.from },
5274
5184
  });
5275
5185
  if (denied) return denied;
5186
+ // Resolve the target through the shared planner — the SAME matcher the MCP send tool
5187
+ // uses (planSend → matchAgents) — so a bare label / name / id-segment that matches an
5188
+ // existing agent is rewritten to its canonical id. Poll-time matching is exact, so
5189
+ // without this a bare label is stored verbatim and reaches no one (#234). An ambiguous
5190
+ // direct ref can't be delivered, so reject it up front. Unknown targets are left
5191
+ // untouched on purpose: sending to an id that isn't registered yet is a supported
5192
+ // "store-ahead" pattern (delivered once that agent registers and polls). Fan-out and
5193
+ // reserved/policy targets carry through unchanged (and may legitimately match zero now).
5194
+ //
5276
5195
  // "session" = observed assistant turn (Phase 1 live-session lane). It is captured
5277
5196
  // from the provider transcript and stored for the dashboard chat; it must persist
5278
5197
  // regardless of target liveness and never be re-delivered into a session.
5279
5198
  const bypassKinds = ["system", "control", "session"];
5280
- if (isDirectTarget(input.to) && !bypassKinds.includes(input.kind ?? "")) {
5199
+ if (!bypassKinds.includes(input.kind ?? "")) {
5200
+ const plan = planSend(input.to, listAgents());
5201
+ if (plan.kind === "ambiguous") return error(plan.message, 409);
5202
+ if (plan.kind !== "not_found") input.to = plan.to;
5203
+ // Long-standing guard: refuse a direct send to a known-offline agent (now also
5204
+ // catches a ref that resolved to an offline agent by label/segment).
5281
5205
  const target = getAgent(input.to);
5282
5206
  if (target && target.status === "offline") {
5283
5207
  return error(`agent "${input.to}" is offline`, 422);
@@ -1,7 +1,20 @@
1
- import { createToken, revokeToken } from "./token-db";
1
+ import { createToken, getTokenProfile, revokeToken } from "./token-db";
2
2
  import { verifyComponentTokenAllowExpired } from "./security";
3
3
  import type { TokenRecord } from "./types";
4
4
 
5
+ // Scopes that turn an agent's runtime token into a spawn-capable one (#221). Appended to the
6
+ // provider-agent base scope only when the resolved profile permits (maxSpawnedAgents > 0).
7
+ // Children never receive these (no grandchildren), so a child's token can never gate the
8
+ // spawn/shutdown MCP tools open. Kept distinct from `agent:write` (self-management) on
9
+ // purpose — that scope must stay for heartbeat/status without implying spawn rights.
10
+ const SPAWN_SCOPES = ["command:spawn", "command:shutdown"] as const;
11
+
12
+ function runnerScopeWithSpawn(canSpawn: boolean): string[] | undefined {
13
+ if (!canSpawn) return undefined; // undefined → createToken falls back to the profile's scope
14
+ const base = getTokenProfile("provider-agent")?.scope ?? [];
15
+ return [...base, ...SPAWN_SCOPES];
16
+ }
17
+
5
18
  interface RuntimeTokenResult {
6
19
  token: string;
7
20
  record: TokenRecord;
@@ -76,6 +89,12 @@ export function issueRunnerRuntimeToken(input: {
76
89
  policyName?: string;
77
90
  spawnRequestId?: string;
78
91
  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;
96
+ /** Parent agent id — stamped so the child registers with an authoritative `spawnedBy`. */
97
+ spawnedBy?: string;
79
98
  }): RuntimeTokenResult {
80
99
  const subject = input.policyName
81
100
  ? `runner:policy:${input.policyName}`
@@ -86,11 +105,14 @@ export function issueRunnerRuntimeToken(input: {
86
105
  profileId: "provider-agent",
87
106
  sub: subject,
88
107
  role: "provider",
108
+ scope: runnerScopeWithSpawn(input.canSpawn ?? false),
89
109
  constraints: {
90
110
  orchestrators: [input.orchestratorId],
91
111
  cwdPrefixes: [input.cwd],
92
112
  ...(input.policyName ? { policies: [input.policyName] } : {}),
93
113
  ...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
114
+ ...(input.canSpawn && input.maxSpawnedAgents ? { maxSpawnedAgents: input.maxSpawnedAgents } : {}),
115
+ ...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
94
116
  },
95
117
  createdBy: input.createdBy ?? "runtime",
96
118
  });
@@ -204,6 +226,9 @@ export function runnerRuntimeTokenEnv(input: {
204
226
  policyName?: string;
205
227
  spawnRequestId?: string;
206
228
  createdBy?: string;
229
+ canSpawn?: boolean;
230
+ maxSpawnedAgents?: number;
231
+ spawnedBy?: string;
207
232
  }): Record<string, string> {
208
233
  const issued = issueRunnerRuntimeToken(input);
209
234
  return {
package/src/security.ts CHANGED
@@ -438,8 +438,10 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
438
438
  for (const [key, item] of Object.entries(record)) {
439
439
  if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
440
440
  if (typeof item !== "boolean") return false;
441
- } else if (key === "cwd") {
441
+ } else if (key === "cwd" || key === "spawnedBy") {
442
442
  if (typeof item !== "string") return false;
443
+ } else if (key === "maxSpawnedAgents") {
444
+ if (typeof item !== "number") return false;
443
445
  } else if (!Array.isArray(item) || !item.every((entry) => typeof entry === "string")) {
444
446
  return false;
445
447
  }