agent-relay-server 0.121.0 → 0.121.1

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 (126) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -4
  3. package/public/assets/{MessageBubble-BaPGFJxq.js → MessageBubble-CLqxCG9b.js} +2 -2
  4. package/public/assets/{MessageBubble-BaPGFJxq.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BXCVfmln.js → PlanGraphPanel-BkkToFEb.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BXCVfmln.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
  7. package/public/assets/{activity-D_shwAZt.js → activity-D-ocGQGs.js} +2 -2
  8. package/public/assets/{activity-D_shwAZt.js.map → activity-D-ocGQGs.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BrfVz5IV.js → agent-profiles-CRPgYTyb.js} +2 -2
  10. package/public/assets/{agent-profiles-BrfVz5IV.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
  11. package/public/assets/{agents-B5w-Z9Ic.js → agents-Chw5E_2q.js} +2 -2
  12. package/public/assets/{agents-B5w-Z9Ic.js.map → agents-Chw5E_2q.js.map} +1 -1
  13. package/public/assets/{analytics-Bm3D12UN.js → analytics-BmNDG0hR.js} +2 -2
  14. package/public/assets/{analytics-Bm3D12UN.js.map → analytics-BmNDG0hR.js.map} +1 -1
  15. package/public/assets/{automation-DOTL8BFs.js → automation-D7g90kTv.js} +2 -2
  16. package/public/assets/{automation-DOTL8BFs.js.map → automation-D7g90kTv.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-BlqeJZ5-.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
  18. package/public/assets/{branch-state-badge-BlqeJZ5-.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
  19. package/public/assets/{channels-BoiKfK6Q.js → channels-CLwPeEPi.js} +2 -2
  20. package/public/assets/{channels-BoiKfK6Q.js.map → channels-CLwPeEPi.js.map} +1 -1
  21. package/public/assets/{chat-PeU3iOaa.js → chat-Cgj7VKzc.js} +2 -2
  22. package/public/assets/{chat-PeU3iOaa.js.map → chat-Cgj7VKzc.js.map} +1 -1
  23. package/public/assets/{connectors-BvV8Hee2.js → connectors-Br6fiTny.js} +2 -2
  24. package/public/assets/{connectors-BvV8Hee2.js.map → connectors-Br6fiTny.js.map} +1 -1
  25. package/public/assets/{formatted-body-C1FgzlT1.js → formatted-body-CmkuD23M.js} +3 -3
  26. package/public/assets/{formatted-body-C1FgzlT1.js.map → formatted-body-CmkuD23M.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-BTpU1xH4.js → formatted-body-impl-DbdFmbwW.js} +2 -2
  28. package/public/assets/{formatted-body-impl-BTpU1xH4.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
  29. package/public/assets/{index-DZEahmyc.js → index-D9OogaB5.js} +6 -6
  30. package/public/assets/{index-DZEahmyc.js.map → index-D9OogaB5.js.map} +1 -1
  31. package/public/assets/{insights-BqmgSpQm.js → insights-qvaPqWCV.js} +2 -2
  32. package/public/assets/{insights-BqmgSpQm.js.map → insights-qvaPqWCV.js.map} +1 -1
  33. package/public/assets/{integrations-CuT92zQi.js → integrations-DYEGSqq_.js} +2 -2
  34. package/public/assets/{integrations-CuT92zQi.js.map → integrations-DYEGSqq_.js.map} +1 -1
  35. package/public/assets/{maintenance-lM7FeXVC.js → maintenance-C0HIEB-J.js} +2 -2
  36. package/public/assets/{maintenance-lM7FeXVC.js.map → maintenance-C0HIEB-J.js.map} +1 -1
  37. package/public/assets/{managed-agents-DaPNv3yY.js → managed-agents-DdS7aI38.js} +2 -2
  38. package/public/assets/{managed-agents-DaPNv3yY.js.map → managed-agents-DdS7aI38.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-Wia3Ho1s.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-Wia3Ho1s.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
  41. package/public/assets/{memory-B1v_zD-M.js → memory-CSwx7bz8.js} +2 -2
  42. package/public/assets/{memory-B1v_zD-M.js.map → memory-CSwx7bz8.js.map} +1 -1
  43. package/public/assets/{messages-BNOx3yK6.js → messages-Be9CmvDv.js} +2 -2
  44. package/public/assets/{messages-BNOx3yK6.js.map → messages-Be9CmvDv.js.map} +1 -1
  45. package/public/assets/{orchestrators-DWFy4asl.js → orchestrators-BgtvvHvo.js} +2 -2
  46. package/public/assets/{orchestrators-DWFy4asl.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
  47. package/public/assets/{overview-C_12cFPM.js → overview-BvhbPWtF.js} +2 -2
  48. package/public/assets/{overview-C_12cFPM.js.map → overview-BvhbPWtF.js.map} +1 -1
  49. package/public/assets/{pairs-DIc7gX83.js → pairs-CpXSOMx0.js} +2 -2
  50. package/public/assets/{pairs-DIc7gX83.js.map → pairs-CpXSOMx0.js.map} +1 -1
  51. package/public/assets/{projects-DPjvJUzT.js → projects-DdPNPFJI.js} +2 -2
  52. package/public/assets/{projects-DPjvJUzT.js.map → projects-DdPNPFJI.js.map} +1 -1
  53. package/public/assets/{prompt-settings-CtumNgG8.js → prompt-settings-BpZse7-X.js} +2 -2
  54. package/public/assets/{prompt-settings-CtumNgG8.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
  55. package/public/assets/{registry-DzUhgMq9.js → registry-pb1gTZEg.js} +2 -2
  56. package/public/assets/{registry-DzUhgMq9.js.map → registry-pb1gTZEg.js.map} +1 -1
  57. package/public/assets/{security-BJ3_u0uY.js → security-BgcX1n9D.js} +2 -2
  58. package/public/assets/{security-BJ3_u0uY.js.map → security-BgcX1n9D.js.map} +1 -1
  59. package/public/assets/{select-AWvlctyQ.js → select-DZPVpKPv.js} +2 -2
  60. package/public/assets/{select-AWvlctyQ.js.map → select-DZPVpKPv.js.map} +1 -1
  61. package/public/assets/{settings-5ofqi-bN.js → settings-hd5kzdRB.js} +2 -2
  62. package/public/assets/{settings-5ofqi-bN.js.map → settings-hd5kzdRB.js.map} +1 -1
  63. package/public/assets/store-DKTLubBT.js +9 -0
  64. package/public/assets/store-DKTLubBT.js.map +1 -0
  65. package/public/assets/{tasks-BEE9bBf7.js → tasks-C5d2LU5E.js} +2 -2
  66. package/public/assets/{tasks-BEE9bBf7.js.map → tasks-C5d2LU5E.js.map} +1 -1
  67. package/public/assets/{teams-C27H_Gwo.js → teams-B8f2pGJe.js} +2 -2
  68. package/public/assets/{teams-C27H_Gwo.js.map → teams-B8f2pGJe.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-WD2TboA7.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-WD2TboA7.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-CtmPK2hK.js → work-queue-9imc7aNK.js} +2 -2
  73. package/public/assets/{work-queue-CtmPK2hK.js.map → work-queue-9imc7aNK.js.map} +1 -1
  74. package/public/assets/{workspaces-B26EKv_H.js → workspaces-CLYc3yF3.js} +2 -2
  75. package/public/assets/{workspaces-B26EKv_H.js.map → workspaces-CLYc3yF3.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
  79. package/src/agent-issue-repo.ts +10 -3
  80. package/src/agent-lifecycle-events.ts +23 -1
  81. package/src/agent-ref.ts +18 -3
  82. package/src/bus-outbox.ts +44 -1
  83. package/src/bus.ts +28 -5
  84. package/src/channel-target.ts +4 -0
  85. package/src/cli/steward.ts +15 -14
  86. package/src/commands-db.ts +63 -9
  87. package/src/config.ts +50 -0
  88. package/src/db/claim-sql.ts +16 -0
  89. package/src/db/continuations.ts +44 -3
  90. package/src/db/message-reads.ts +34 -4
  91. package/src/db/messages.ts +128 -4
  92. package/src/db/pairs.ts +1 -0
  93. package/src/db/schema.ts +6 -0
  94. package/src/gate-resolver.ts +124 -0
  95. package/src/lifecycle-manager.ts +41 -10
  96. package/src/lifecycle-signal-registry.ts +87 -0
  97. package/src/maintenance/constants.ts +5 -1
  98. package/src/maintenance/jobs.ts +28 -2
  99. package/src/maintenance/workspaces.ts +75 -44
  100. package/src/managed-policy-escalation.ts +68 -3
  101. package/src/mcp/tools-spawn.ts +16 -6
  102. package/src/mcp-plan-tools.ts +2 -3
  103. package/src/notification-settings.ts +11 -0
  104. package/src/notification-types.ts +4 -0
  105. package/src/operator-alarm.ts +37 -0
  106. package/src/routes/agents.ts +4 -0
  107. package/src/routes/orchestrator-proxy.ts +13 -1
  108. package/src/routes/orchestrator.ts +4 -0
  109. package/src/security.ts +20 -0
  110. package/src/self-resume.ts +185 -21
  111. package/src/server.ts +2 -0
  112. package/src/services/plan-graph.ts +42 -12
  113. package/src/services/register-agent.ts +5 -1
  114. package/src/services/send-message.ts +36 -0
  115. package/src/services/spawn-agent.ts +21 -2
  116. package/src/services/task-semantic-events.ts +17 -1
  117. package/src/spawn-command.ts +3 -0
  118. package/src/spawn-targets.ts +26 -1
  119. package/src/sse.ts +13 -5
  120. package/src/workspace-auto-merge.ts +90 -34
  121. package/src/workspace-escalation.ts +52 -3
  122. package/src/workspace-human-escalation.ts +68 -0
  123. package/src/workspace-orphans.ts +11 -6
  124. package/src/workspace-phase.ts +22 -1
  125. package/public/assets/store-D5et8685.js +0 -9
  126. package/public/assets/store-D5et8685.js.map +0 -1
@@ -21,7 +21,8 @@ import { preparePrCompletionScan, reconcileLandedPr } from "../workspace-pr-comp
21
21
  import { applyBranchFreshness, fetchHostMergePreview, workspacePathWithinBase } from "../workspace-probe";
22
22
  import { wakeRepoSteward } from "../workspace-auto-merge";
23
23
  import { workspaceOwnerOrchestrator } from "../workspace-host";
24
- import { routeWorkspaceCustodyEscalation } from "../workspace-escalation";
24
+ import { routeWorkspaceCustodyEscalation, routeWorkspaceCustodyEscalationOrAlarm } from "../workspace-escalation";
25
+ import { raiseOperatorAlarm } from "../operator-alarm";
25
26
  import {
26
27
  CONFLICT_SCAN_STATUSES,
27
28
  DAY_MS,
@@ -31,7 +32,6 @@ import {
31
32
  WORKSPACE_RETENTION_MS,
32
33
  WORKSPACE_REVIEW_TTL_MS,
33
34
  stewardEscalationMs,
34
- stewardFallbackTarget,
35
35
  } from "./constants";
36
36
  import { emitCommand, notifyTarget } from "./events";
37
37
 
@@ -97,8 +97,8 @@ export async function scanWorkspaceConflicts(): Promise<Record<string, unknown>>
97
97
  metadata: { source: "server", maintenanceJobId: "workspace-conflict-scan", workspaceId: ws.id, ahead: p.ahead, behind: p.behind },
98
98
  });
99
99
  if (getStewardConfig().enabled) {
100
- const woke = wakeRepoSteward(getWorkspace(ws.id) ?? ws, "conflict");
101
- if (woke) notifiedStewards.push(woke);
100
+ const wake = wakeRepoSteward(getWorkspace(ws.id) ?? ws, "conflict");
101
+ if (wake.ok) notifiedStewards.push(wake.policy);
102
102
  } else if (ws.stewardAgentId) {
103
103
  try {
104
104
  // #640 — carry the identity triangle (branch agent + its coordinator) so the
@@ -146,7 +146,12 @@ function previewProvesLanded(preview: WorkspaceMergePreview): boolean {
146
146
 
147
147
  async function reviewAutoAbandonDecision(ws: WorkspaceRecord, orchestrators: ReturnType<typeof listOrchestrators>): Promise<AutoAbandonDecision> {
148
148
  const owner = workspaceOwnerOrchestrator(ws, orchestrators, { requireApiUrl: true });
149
- if (!owner?.apiUrl) return ws.orchestratorId ? { kind: "defer", reason: "owner-unavailable" } : { kind: "abandon" };
149
+ // #1025 C3 no reachable owner means we cannot probe land-state. Deferring is the safe
150
+ // outcome for BOTH cases: a known-but-offline host (may reconnect) AND an unknown host (no
151
+ // orchestratorId, no path match). The old code abandoned the unknown-host case BLIND — the
152
+ // less-certain input got the more destructive outcome. Deferring instead keeps the row for
153
+ // the strand-escalation loop / orphan scan to handle with an actual probe.
154
+ if (!owner?.apiUrl) return { kind: "defer", reason: ws.orchestratorId ? "owner-unavailable" : "unknown-host" };
150
155
  const preview = await fetchHostMergePreview(owner.apiUrl, ws, { checkPr: true });
151
156
  if (!preview || (preview as { available?: false }).available === false) return { kind: "defer", reason: "owner-unavailable" };
152
157
  const p = preview as WorkspaceMergePreview;
@@ -204,13 +209,28 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
204
209
  if (getStewardConfig().enabled) {
205
210
  wakeRepoSteward(fresh, `workspace ${fresh.status} stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h`);
206
211
  }
212
+ const strandedHours = Math.round((now - strandedAt) / (60 * 60 * 1000));
207
213
  const targets = routeWorkspaceCustodyEscalation({
208
214
  workspace: fresh,
209
215
  subject: "Stranded workspace needs an owner",
210
- body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} and has been stranded for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
216
+ body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} and has been stranded for ${strandedHours}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
211
217
  payload: { kind: "workspace.stranded-escalation", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
212
218
  idempotencyKey: `workspace-stranded:${fresh.id}:${strandedAt}:${Math.floor(now / escalationMs)}`,
213
219
  });
220
+ if (!targets.length) {
221
+ // #1021 A4 — the escalation reached ZERO recipients (owner exited, coordinator idle, no
222
+ // fallback configured). Marking this "escalated" would silently swallow the strand and
223
+ // repeat every sweep forever. Instead leave it UNescalated (don't stamp escalatedAt, so
224
+ // the next sweep retries once recipients exist) and raise a louder operator-visible alarm.
225
+ raiseOperatorAlarm({
226
+ clientId: `workspace-gc-unescalatable-${fresh.id}:${strandedAt}:${Math.floor(now / escalationMs)}`,
227
+ source: "workspace-gc",
228
+ subject: "Stranded workspace has no one to escalate to",
229
+ detail: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} has been ${fresh.status} for ${strandedHours}h but has no owner, coordinator, or fallback configured to escalate to. Configure AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK or reclaim the worktree manually.`,
230
+ metadata: { maintenanceJobId: "workspace-gc", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status, strandedAt },
231
+ });
232
+ continue;
233
+ }
214
234
  escalationTargets.push(...targets);
215
235
  patchWorkspaceMetadata(fresh.id, { escalatedAt: now });
216
236
  escalatedIds.push(fresh.id);
@@ -218,7 +238,7 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
218
238
  clientId: `workspace-gc-escalate-${fresh.id}-${now}`,
219
239
  kind: "state",
220
240
  title: "Workspace escalated",
221
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated${targets.length ? ` to ${targets.join(", ")}` : " (no owner, coordinator, or fallback configured)"}`,
241
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - stranded ${fresh.status} escalated to ${targets.join(", ")}`,
222
242
  meta: fresh.branch ?? fresh.id,
223
243
  icon: "ti-alert-octagon",
224
244
  view: "orchestrators",
@@ -230,45 +250,56 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
230
250
  const autoAbandonDeferred: string[] = [];
231
251
  const notifiedStewards: string[] = [];
232
252
  const reviewOrchestrators = listOrchestrators();
233
- const fallbackTarget = stewardFallbackTarget();
234
253
  for (const ws of all) {
235
- if (ws.status === "review_requested" && ws.updatedAt < reviewCutoff) {
236
- const fresh = getWorkspace(ws.id);
237
- if (!fresh || fresh.status !== "review_requested") continue;
238
- const decision = await reviewAutoAbandonDecision(fresh, reviewOrchestrators);
239
- if (decision.kind === "defer") {
240
- autoAbandonDeferred.push(`${fresh.id}:${decision.reason}`);
241
- continue;
242
- }
243
- if (decision.kind === "landed") {
244
- reconcileLandedWorkspace(fresh, decision.preview);
245
- reconciledLandedIds.push(fresh.id);
246
- continue;
247
- }
248
-
249
- updateWorkspaceStatus(fresh.id, "abandoned", { autoAbandoned: true, abandonedReason: "review_requested TTL exceeded", abandonedAt: now });
250
- abandonedIds.push(fresh.id);
251
- const target = fresh.stewardAgentId ?? fallbackTarget;
252
- const sent = notifyTarget(
253
- "workspace.auto_abandoned",
254
- { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
255
- target,
256
- "Workspace auto-abandoned",
257
- `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
258
- { kind: "workspace.auto-abandoned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch },
259
- );
260
- if (sent) notifiedStewards.push(sent);
261
- createActivityEvent({
262
- clientId: `workspace-gc-abandon-${fresh.id}-${now}`,
263
- kind: "state",
264
- title: "Workspace auto-abandoned",
265
- body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - review_requested for ${Math.round((now - fresh.updatedAt) / DAY_MS)}d`,
266
- meta: fresh.branch ?? fresh.id,
267
- icon: "ti-clock-x",
268
- view: "orchestrators",
269
- metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id },
270
- });
254
+ if (ws.mode !== "isolated" || !ws.worktreePath) continue;
255
+ // #1025 C2 — auto-abandon covers the whole strandable set (ready, review_requested,
256
+ // conflict, merge_planned), not just `review_requested`; a stuck `ready` was exempt and
257
+ // leaked forever. Clock the TTL on `readyAt` (the stable hand-off time) rather than
258
+ // `updatedAt` updatedAt bumps on every owner heartbeat, so a live-but-idle owner's
259
+ // workspace never aged past the TTL. Fall back to updatedAt when readyAt is absent
260
+ // (e.g. an active→conflict autoflag that never passed through `ready`).
261
+ if (!STRANDABLE_STATUSES.has(ws.status)) continue;
262
+ if ((ws.readyAt ?? ws.updatedAt) >= reviewCutoff) continue;
263
+ const fresh = getWorkspace(ws.id);
264
+ if (!fresh || !STRANDABLE_STATUSES.has(fresh.status)) continue;
265
+ if ((fresh.readyAt ?? fresh.updatedAt) >= reviewCutoff) continue;
266
+ const decision = await reviewAutoAbandonDecision(fresh, reviewOrchestrators);
267
+ if (decision.kind === "defer") {
268
+ autoAbandonDeferred.push(`${fresh.id}:${decision.reason}`);
269
+ continue;
271
270
  }
271
+ if (decision.kind === "landed") {
272
+ reconcileLandedWorkspace(fresh, decision.preview);
273
+ reconciledLandedIds.push(fresh.id);
274
+ continue;
275
+ }
276
+
277
+ updateWorkspaceStatus(fresh.id, "abandoned", { autoAbandoned: true, abandonedReason: `${fresh.status} TTL exceeded`, abandonedAt: now });
278
+ abandonedIds.push(fresh.id);
279
+ // #1025 C4 — route through the owner/coordinator/fallback custody chain (with a loud
280
+ // operator alarm when it resolves to nobody) instead of `stewardAgentId ?? fallback`.
281
+ // Most strandable workspaces have no steward, so the old notice silently dropped while
282
+ // the state change still happened.
283
+ const targets = routeWorkspaceCustodyEscalationOrAlarm({
284
+ source: "workspace-gc",
285
+ workspace: fresh,
286
+ type: "workspace.auto_abandoned",
287
+ subject: "Workspace auto-abandoned",
288
+ body: `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d ${fresh.status} without action. Run workspace cleanup to reclaim the worktree.`,
289
+ payload: { kind: "workspace.auto-abandoned", workspaceId: fresh.id, repoRoot: fresh.repoRoot, branch: fresh.branch, status: fresh.status },
290
+ idempotencyKey: `workspace-auto-abandoned:${fresh.id}:${now}`,
291
+ });
292
+ if (targets.length) notifiedStewards.push(...targets);
293
+ createActivityEvent({
294
+ clientId: `workspace-gc-abandon-${fresh.id}-${now}`,
295
+ kind: "state",
296
+ title: "Workspace auto-abandoned",
297
+ body: `${fresh.branch ?? fresh.id} in ${fresh.repoRoot} - ${fresh.status} past the ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d TTL`,
298
+ meta: fresh.branch ?? fresh.id,
299
+ icon: "ti-clock-x",
300
+ view: "orchestrators",
301
+ metadata: { source: "server", maintenanceJobId: "workspace-gc", workspaceId: fresh.id, status: fresh.status },
302
+ });
272
303
  }
273
304
 
274
305
  const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.agentId);
@@ -2,16 +2,81 @@ import { getAgent, getDb } from "./db";
2
2
  import { getSpawnPolicy } from "./config-store";
3
3
  import { stewardFallbackTarget } from "./config";
4
4
  import { routeNotification } from "./notification-router";
5
+ import { raiseOperatorAlarm } from "./operator-alarm";
5
6
  import type { ManagedAgentState, Orchestrator, SpawnPolicy } from "./types";
6
7
 
7
8
  const ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES = 3;
8
9
 
9
- export function escalateOfflineOrchestrator(policy: SpawnPolicy, state: ManagedAgentState, orch: Orchestrator | null | undefined): void {
10
- if (state.consecutiveFailures < ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES) return;
10
+ function managedPolicyEscalationRecipients(policy: SpawnPolicy, state: ManagedAgentState): string[] {
11
11
  const updatedBy = getSpawnPolicy(policy.name)?.updatedBy?.trim();
12
12
  const previousParent = state.agentId ? getAgent(state.agentId)?.spawnedBy?.trim() : undefined;
13
13
  const fallback = stewardFallbackTarget();
14
- const recipients = [...new Set([updatedBy, previousParent, fallback].filter((item): item is string => Boolean(item && item !== "system")))];
14
+ return [...new Set([updatedBy, previousParent, fallback].filter((item): item is string => Boolean(item && item !== "system")))];
15
+ }
16
+
17
+ // #1021 A3 — the spawn chain used to escalate only the "orchestrator offline" branch; every
18
+ // OTHER terminal failure (crash-on-boot fast-fail storm → status `failed` → reconcilePolicy
19
+ // returns forever; a spawn that dispatched but never registered a live session → 120s timeout
20
+ // that backs off forever) emitted dashboard state only, so nobody was ever told the managed
21
+ // agent — often the repo steward that lands branches — is down. Route those to the same
22
+ // recipients as the offline branch, and when there is no reachable recipient raise an
23
+ // operator-visible alarm instead of silently dropping the signal.
24
+ export function escalateManagedSpawnFailure(
25
+ policy: SpawnPolicy,
26
+ state: ManagedAgentState,
27
+ opts: { phase: "failed" | "register-timeout"; error: string },
28
+ ): void {
29
+ const recipients = managedPolicyEscalationRecipients(policy, state);
30
+ const queuedMessages = queuedPolicyMessageCount(policy.name);
31
+ const subject = opts.phase === "failed"
32
+ ? "Managed policy failed and stopped retrying"
33
+ : "Managed policy spawn never registered";
34
+ const body = [
35
+ opts.phase === "failed"
36
+ ? `Managed policy \`${policy.name}\` has failed and will not retry on its own (${opts.error}).`
37
+ : `Managed policy \`${policy.name}\` keeps spawning but no session ever registers (${opts.error}).`,
38
+ `Queued messages: ${queuedMessages}. Consecutive failures: ${state.consecutiveFailures}.`,
39
+ ].join("\n");
40
+ const payload = {
41
+ kind: opts.phase === "failed" ? "managed-policy.failed" : "managed-policy.register-timeout",
42
+ policyName: policy.name,
43
+ orchestratorId: policy.orchestratorId,
44
+ queuedMessages,
45
+ consecutiveFailures: state.consecutiveFailures,
46
+ error: opts.error,
47
+ };
48
+ if (!recipients.length) {
49
+ raiseOperatorAlarm({
50
+ clientId: `managed-policy-alarm-${opts.phase}-${policy.name}-${policy.orchestratorId}`,
51
+ source: "lifecycle-manager",
52
+ subject,
53
+ detail: `${body}\nNo owner, coordinator, or fallback is reachable to page.`,
54
+ view: "managed-agents",
55
+ metadata: payload,
56
+ });
57
+ return;
58
+ }
59
+ routeNotification({
60
+ type: "agent.spawn_failed",
61
+ scope: { target: `policy:${policy.name}` },
62
+ recipients,
63
+ message: {
64
+ subject,
65
+ body,
66
+ payload,
67
+ // Once per failed episode (failed: this spawn's request id; register-timeout: once per
68
+ // policy+host) — dedups so a wedged policy pages the coordinator once, not every tick.
69
+ idempotencyKey: opts.phase === "failed"
70
+ ? `managed-policy-failed:${policy.name}:${state.spawnRequestId ?? state.consecutiveFailures}`
71
+ : `managed-policy-register-timeout:${policy.name}:${policy.orchestratorId}`,
72
+ replyExpected: false,
73
+ },
74
+ });
75
+ }
76
+
77
+ export function escalateOfflineOrchestrator(policy: SpawnPolicy, state: ManagedAgentState, orch: Orchestrator | null | undefined): void {
78
+ if (state.consecutiveFailures < ORCHESTRATOR_OFFLINE_ESCALATION_FAILURES) return;
79
+ const recipients = managedPolicyEscalationRecipients(policy, state);
15
80
  if (!recipients.length) return;
16
81
  const host = orch?.hostname ? `${orch.hostname} (${policy.orchestratorId})` : policy.orchestratorId;
17
82
  const queuedMessages = queuedPolicyMessageCount(policy.name);
@@ -12,7 +12,7 @@ import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } f
12
12
  import { listCommands } from "../commands-db";
13
13
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
14
14
  import { optionalEnum } from "../validation";
15
- import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
15
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, REPORT_VERBOSITIES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
16
16
  import type { ProviderEffort } from "agent-relay-sdk/provider-catalog";
17
17
  import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode, WorkspaceRecord } from "../types";
18
18
  import type { McpAuthContext, ToolDefinition } from "./types";
@@ -74,7 +74,9 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
74
74
  required: ["branch", "mode"],
75
75
  additionalProperties: false,
76
76
  },
77
+ reportVerbosity: { type: "string", enum: REPORT_VERBOSITIES, description: "#1037 — how much of this worker's turn output reaches you (the spawner). `quiet` (default): only the worker's turn-final report, and only once it goes idle/awaiting-input — mid-task narration is suppressed. `normal`: every turn-final response, forwarded immediately. `verbose`: every turn-final response plus all narration/reasoning/tool steps. Lifecycle signals (ready/exit/merge), escalations, and messages the worker addresses to you directly always arrive regardless." },
77
78
  waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 0 = fire-and-forget; you will be PUSHED agent.ready when the child is ready)." },
79
+ verbose: { type: "boolean", description: "#1033 — return the FULL spawn detail (resolved agent profile + provisioning manifest + the dispatched command payload) instead of the default lean receipt. Off by default: the lean receipt is `{ok, spawnRequestId, agentId, taskId, orchestratorId, provider, registered, status}` — everything you need to track/shutdown the child, without the ~50-60KB profile/manifest + triple prompt echo that blows the tool-result cap. Set true only when you actually need to inspect the resolved provisioning." },
78
80
  },
79
81
  required: ["provider"],
80
82
  additionalProperties: false,
@@ -166,21 +168,29 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
166
168
  requestedVia: "mcp",
167
169
  waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 0,
168
170
  resumeWorkspace: parseResumeWorkspace(args.resumeWorkspace),
171
+ reportVerbosity: optionalEnum(args.reportVerbosity, "reportVerbosity", REPORT_VERBOSITIES),
169
172
  };
173
+ const verbose = optionalBoolean(args.verbose, "verbose") === true;
170
174
  const result = await spawnAgent(input, authContextFromMcp(auth));
171
- // #734return the lean shape callers need (spawnRequestId + agentId for idempotency/shutdown)
172
- // plus a sanitized command summary. The full resolved provisioning already went to the
173
- // orchestrator on the dispatched command; echoing it here blows the MCP result token cap.
174
- return {
175
+ // #1033LEAN RECEIPT by default. The full resolved provisioning (agent profile + manifest +
176
+ // plugin file listing) and the hydrated prompt (echoed three times across params.prompt,
177
+ // systemPromptAppend and relayInjectionEvents) already went to the orchestrator on the DISPATCHED
178
+ // command; echoing any of it back to the caller is ~50-60KB that blows the MCP result token cap
179
+ // (#1033) for zero orchestration value — the caller only needs the identity fields to track /
180
+ // shutdown the child. Full detail (behind #734's content-stripped summary) is opt-in via `verbose`.
181
+ const receipt: Record<string, unknown> = {
175
182
  ok: result.ok,
176
183
  spawnRequestId: result.spawnRequestId,
177
184
  orchestratorId: result.orchestratorId,
178
185
  provider: result.provider,
179
186
  agentId: result.agentId,
180
187
  registered: result.registered,
181
- command: summarizeSpawnCommandForCaller(result.command),
188
+ status: result.registered ? "registered" : "pending",
189
+ ...(result.taskId !== undefined ? { taskId: result.taskId } : {}),
182
190
  ...(result.task ? { task: result.task } : {}),
183
191
  };
192
+ if (verbose) receipt.command = summarizeSpawnCommandForCaller(result.command);
193
+ return receipt;
184
194
  }
185
195
 
186
196
  export function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
@@ -22,7 +22,7 @@ const PLAN_NODE_REFS_SCHEMA = {
22
22
  type: "object",
23
23
  properties: {
24
24
  taskId: { oneOf: [{ type: "string" }, { type: "integer", minimum: 1 }] },
25
- issueId: { type: "string" },
25
+ issueId: { type: "string", description: "issue_<id>, #number, owner/repo#number, or a GitHub issue URL. A bare #number resolves against the calling agent's own project repo (cwd → project → issueRepo), falling back to AGENT_RELAY_DEFAULT_ISSUE_REPO/GITHUB_REPOSITORY (#220) — it throws if neither is configured." },
26
26
  },
27
27
  additionalProperties: false,
28
28
  } as const;
@@ -56,7 +56,7 @@ const PLAN_EDGE_SCHEMA = {
56
56
  export const PLAN_TOOLS = [
57
57
  {
58
58
  name: "relay_plan_create",
59
- description: "Create a persisted, versioned plan DAG attached to a message thread/channel. MVP statusSource is always manual.",
59
+ description: "Create a persisted, versioned plan DAG attached to a message thread/channel. threadId is optional — omit it and a thread is minted server-side (#1035), so a fresh coordinator needn't send a throwaway message just to anchor a plan. MVP statusSource is always manual.",
60
60
  requiredScopes: ["plans:write"],
61
61
  inputSchema: {
62
62
  type: "object",
@@ -69,7 +69,6 @@ export const PLAN_TOOLS = [
69
69
  nodes: { type: "array", items: PLAN_NODE_SCHEMA },
70
70
  edges: { type: "array", items: PLAN_EDGE_SCHEMA },
71
71
  },
72
- required: ["threadId"],
73
72
  additionalProperties: false,
74
73
  },
75
74
  },
@@ -25,6 +25,7 @@ export const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = {
25
25
  agentReady: true,
26
26
  agentExited: true,
27
27
  agentSpawnFailed: true,
28
+ humanEscalationChannelId: "ntfy:default",
28
29
  contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG,
29
30
  quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG,
30
31
  };
@@ -63,6 +64,7 @@ export const NOTIFICATIONS_SETTINGS_SCHEMA: JSONSchema = {
63
64
  agentReady: { type: "boolean" },
64
65
  agentExited: { type: "boolean" },
65
66
  agentSpawnFailed: { type: "boolean" },
67
+ humanEscalationChannelId: { type: "string", maxLength: 200 },
66
68
  contextThreshold: CONTEXT_THRESHOLD_SETTINGS_SCHEMA,
67
69
  quotaThreshold: QUOTA_THRESHOLD_SETTINGS_SCHEMA,
68
70
  },
@@ -103,6 +105,7 @@ export function validateNotificationSettingsLayer(value: unknown): NotificationS
103
105
  ...(bool("agentReady") !== undefined ? { agentReady: bool("agentReady")! } : {}),
104
106
  ...(bool("agentExited") !== undefined ? { agentExited: bool("agentExited")! } : {}),
105
107
  ...(bool("agentSpawnFailed") !== undefined ? { agentSpawnFailed: bool("agentSpawnFailed")! } : {}),
108
+ ...(value.humanEscalationChannelId === undefined ? {} : { humanEscalationChannelId: cleanOptionalString(value.humanEscalationChannelId, "humanEscalationChannelId", 200) }),
106
109
  ...(value.contextThreshold === undefined ? {} : { contextThreshold: validateContextThresholdConfig(value.contextThreshold) }),
107
110
  ...(value.quotaThreshold === undefined ? {} : { quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold) }),
108
111
  };
@@ -144,6 +147,14 @@ function cleanBoolean(value: unknown, field: string): boolean {
144
147
  return value;
145
148
  }
146
149
 
150
+ function cleanOptionalString(value: unknown, field: string, max: number): string {
151
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
152
+ const clean = value.trim();
153
+ if (clean.length > max) throw new ValidationError(`${field} must be at most ${max} characters`);
154
+ if (clean.includes("\n") || clean.includes("\r")) throw new ValidationError(`${field} must not contain newlines`);
155
+ return clean;
156
+ }
157
+
147
158
  function cleanLayerId(value: string, field: string): string {
148
159
  const clean = value.trim();
149
160
  if (!clean) throw new ValidationError(`${field} required`);
@@ -28,6 +28,7 @@ export type NotificationType =
28
28
  | "agent.quota_threshold"
29
29
  | "agent.resume_budget_warning"
30
30
  | "agent.resume_budget_exhausted"
31
+ | "agent.resume_injection_failed"
31
32
  | "agent.transient_land_hold"
32
33
  | "agent.rate_limit_resume"
33
34
  | "agent.provider_blocked"
@@ -127,6 +128,9 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
127
128
  "agent.quota_threshold": 15 * MINUTES,
128
129
  "agent.resume_budget_warning": null,
129
130
  "agent.resume_budget_exhausted": null,
131
+ // #1023 — the agent may be running with an empty/stale context; durable like the other
132
+ // resume-lifecycle notices above, not a bystander ping that should silently expire.
133
+ "agent.resume_injection_failed": null,
130
134
  "agent.transient_land_hold": null,
131
135
  "agent.rate_limit_resume": 15 * MINUTES,
132
136
  "agent.provider_blocked": null,
@@ -0,0 +1,37 @@
1
+ import { createActivityEvent } from "./db";
2
+ import { emitRelayEvent } from "./events";
3
+
4
+ export interface OperatorAlarmInput {
5
+ /** Stable client id → repeated alarms for the same condition dedup instead of spamming the timeline. */
6
+ clientId: string;
7
+ source: string;
8
+ subject: string;
9
+ detail: string;
10
+ view?: string;
11
+ metadata?: Record<string, unknown>;
12
+ }
13
+
14
+ // A "louder", operator-visible signal for a condition that would otherwise be swallowed —
15
+ // specifically an escalation that resolved to ZERO reachable recipients (#1021 A3/A4). A
16
+ // normal escalation pages an agent; when there is no agent to page (owner exited,
17
+ // coordinator idle, no fallback configured) the escalation must not silently mark itself
18
+ // done. This raises a distinct `operator.alarm` relay event (dashboard banner) plus a
19
+ // persisted `operator`-kind activity event, so a relay with no reachable escalation target
20
+ // is observable instead of looping forever in the dark.
21
+ export function raiseOperatorAlarm(input: OperatorAlarmInput): void {
22
+ emitRelayEvent({
23
+ type: "operator.alarm",
24
+ source: input.source,
25
+ subject: input.subject,
26
+ data: { subject: input.subject, detail: input.detail, ...(input.metadata ?? {}) },
27
+ });
28
+ createActivityEvent({
29
+ clientId: input.clientId,
30
+ kind: "operator",
31
+ title: input.subject,
32
+ body: input.detail,
33
+ icon: "ti-alert-octagon",
34
+ view: input.view ?? "orchestrators",
35
+ metadata: { source: input.source, operatorAlarm: true, ...(input.metadata ?? {}) },
36
+ });
37
+ }
@@ -9,6 +9,7 @@ import { emitAgentRemoved, emitAgentStatus } from "../sse";
9
9
  import { notifyAgentReady } from "../agent-lifecycle-events";
10
10
  import { bindSpawnedTaskToReadyAgent } from "../services/register-agent";
11
11
  import { flushQueuedDirectMessages } from "../services/managed-running";
12
+ import { flushIdleLineageReport } from "../services/send-message";
12
13
  import { isAgentUnavailable } from "../db/agent-predicates";
13
14
  import { getCompactionWatch } from "../compaction-watch";
14
15
  import { isRecord } from "agent-relay-sdk";
@@ -198,6 +199,9 @@ export const patchAgentStatus: Handler = async (req, params) => {
198
199
  const before = getAgent(params.id!);
199
200
  if (!setStatus(params.id!, body.status as (typeof VALID_AGENT_STATUSES)[number], guard)) return error("agent not found", 404);
200
201
  auditAgentStateTransition(params.id!, before, getAgent(params.id!));
202
+ // #1040 — the HTTP status PATCH is also a busy→idle edge (a runner without a live bus socket).
203
+ // Flush any held turn-final report-up so it reaches the spawner on this path too.
204
+ if (before?.status === "busy" && body.status !== "busy") flushIdleLineageReport(params.id!);
201
205
  } catch (e) {
202
206
  if (e instanceof ValidationError) return error(e.message, 400);
203
207
  throw e;
@@ -5,7 +5,7 @@ import { authorizeRoute, cleanJsonArray, error, json, type Handler } from "./_sh
5
5
  import { cleanStringArray } from "../validation";
6
6
  import { emitOrchestratorStatus } from "../sse";
7
7
  import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
8
- import { relayToken } from "../config";
8
+ import { isTrustedOrchestratorApiUrl, relayToken } from "../config";
9
9
  import { isTerminalSessionRequestAuthorized } from "../terminal-authz";
10
10
 
11
11
  export const getOrchestratorDirectories: Handler = async (req, params) => {
@@ -13,6 +13,9 @@ export const getOrchestratorDirectories: Handler = async (req, params) => {
13
13
  if (!orch) return error("orchestrator not found", 404);
14
14
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
15
15
  if (orch.status !== "online") return error("orchestrator is offline", 422);
16
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
17
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
18
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
16
19
  const url = new URL(req.url);
17
20
  const path = url.searchParams.get("path") || "";
18
21
  const proxyUrl = `${orch.apiUrl}/api/directories${path ? `?path=${encodeURIComponent(path)}` : ""}`;
@@ -30,6 +33,9 @@ export const postOrchestratorCreateDirectory: Handler = async (req, params) => {
30
33
  if (!orch) return error("orchestrator not found", 404);
31
34
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
32
35
  if (orch.status !== "online") return error("orchestrator is offline", 422);
36
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
37
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
38
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
33
39
  try {
34
40
  const body = await req.json();
35
41
  const res = await fetch(`${orch.apiUrl}/api/directories`, {
@@ -62,6 +68,9 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
62
68
  if (!orch) return error("orchestrator not found", 404);
63
69
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
64
70
  if (orch.status !== "online") return error("orchestrator is offline", 422);
71
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
72
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
73
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
65
74
  const incoming = new URL(req.url);
66
75
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
67
76
  const headers: Record<string, string> = {};
@@ -85,6 +94,9 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
85
94
  if (!orch) return error("orchestrator not found", 404);
86
95
  if (!orch.apiUrl) return error("orchestrator does not expose an API", 422);
87
96
  if (orch.status !== "online") return error("orchestrator is offline", 422);
97
+ // #1024 — never forward the relay master token (or the caller's query string) to an untrusted
98
+ // apiUrl. Refuse the proxy outright rather than leak credentials to an attacker-controlled host.
99
+ if (!isTrustedOrchestratorApiUrl(orch.apiUrl)) return error("orchestrator apiUrl is not a trusted origin", 502);
88
100
  const incoming = new URL(req.url);
89
101
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
90
102
  const headers: Record<string, string> = {
@@ -11,6 +11,7 @@ import { createCommand, updateCommand } from "../commands-db";
11
11
  import { emitAgentStatus, emitOrchestratorStatus } from "../sse";
12
12
  import { getLifecycleManager } from "../lifecycle-manager";
13
13
  import { issueOrchestratorRuntimeToken } from "../runtime-tokens";
14
+ import { isOrchestratorRegistrationAuthorized } from "../security";
14
15
  import { type ManagedAgent, type ManagedSessionExitDiagnostics, type OrchestratorRuntimeInput, type RegisterOrchestratorInput, type SpawnApprovalMode, type SpawnProvider } from "../types";
15
16
 
16
17
  function cleanProtocolVersion(value: unknown): number | undefined {
@@ -51,6 +52,9 @@ function cleanRuntimeCapabilities(value: unknown): RuntimeCapabilities | undefin
51
52
  }
52
53
 
53
54
  export const postOrchestrator: Handler = async (req) => {
55
+ // #1024 — registering/upserting an orchestrator is admin-class; a provider-agent runtime token
56
+ // must NOT be able to seed a rogue orchestrator row (its apiUrl is the master-token exfil sink).
57
+ if (!isOrchestratorRegistrationAuthorized(req)) return error("forbidden", 403);
54
58
  const parsed = await parseBody<unknown>(req);
55
59
  if (!parsed.ok) return error(parsed.error, parsed.status);
56
60
  try {
package/src/security.ts CHANGED
@@ -153,6 +153,26 @@ export function isComponentAuthorizedFor(auth: ComponentToken, check: Authorizat
153
153
  return hasComponentScope(auth, check.scope) && constraintsAllow(auth, check.resource);
154
154
  }
155
155
 
156
+ // #1024 — Orchestrator registration/upsert (POST /api/orchestrators) is a privileged bootstrap
157
+ // operation: the row it writes carries an attacker-controllable `apiUrl` that the orchestrator
158
+ // proxy later hands the relay MASTER token to (the C1 admin-token-exfil chain). The coarse scope
159
+ // gate only requires `command:write` — which every routine provider-agent runtime token holds —
160
+ // so gate it explicitly here. Allowed callers: the raw root/god credential, an admin
161
+ // (`system:admin`) token, or an established orchestrator-host token (role "orchestrator", which
162
+ // re-registers on every reconnect). A provider-agent runtime token is refused, closing the chain
163
+ // at its entry point.
164
+ export function isOrchestratorRegistrationAuthorized(req: Request): boolean {
165
+ const component = getComponentAuth(req);
166
+ if (component) {
167
+ return hasComponentScope(component, "system:admin") || component.role === "orchestrator";
168
+ }
169
+ // Not a component token: the raw root credential, an integration token, or no credential at all.
170
+ // The coarse gate (which runs before this handler) already governs those — a routine unauthorized
171
+ // request never reaches here, and the root credential is the legitimate bootstrap path. Mirror
172
+ // authorizeRoute's contract and don't impose a stricter bar than the rest of the route surface.
173
+ return true;
174
+ }
175
+
156
176
  export function requiredScopeFor(method: string, pathname: string): string | null {
157
177
  if (pathname === "/api/stats") return "stats:read";
158
178
  if (pathname === "/api/health") return "health:read";