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.
- package/docs/openapi.json +1 -1
- package/package.json +5 -4
- package/public/assets/{MessageBubble-BaPGFJxq.js → MessageBubble-CLqxCG9b.js} +2 -2
- package/public/assets/{MessageBubble-BaPGFJxq.js.map → MessageBubble-CLqxCG9b.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BXCVfmln.js → PlanGraphPanel-BkkToFEb.js} +2 -2
- package/public/assets/{PlanGraphPanel-BXCVfmln.js.map → PlanGraphPanel-BkkToFEb.js.map} +1 -1
- package/public/assets/{activity-D_shwAZt.js → activity-D-ocGQGs.js} +2 -2
- package/public/assets/{activity-D_shwAZt.js.map → activity-D-ocGQGs.js.map} +1 -1
- package/public/assets/{agent-profiles-BrfVz5IV.js → agent-profiles-CRPgYTyb.js} +2 -2
- package/public/assets/{agent-profiles-BrfVz5IV.js.map → agent-profiles-CRPgYTyb.js.map} +1 -1
- package/public/assets/{agents-B5w-Z9Ic.js → agents-Chw5E_2q.js} +2 -2
- package/public/assets/{agents-B5w-Z9Ic.js.map → agents-Chw5E_2q.js.map} +1 -1
- package/public/assets/{analytics-Bm3D12UN.js → analytics-BmNDG0hR.js} +2 -2
- package/public/assets/{analytics-Bm3D12UN.js.map → analytics-BmNDG0hR.js.map} +1 -1
- package/public/assets/{automation-DOTL8BFs.js → automation-D7g90kTv.js} +2 -2
- package/public/assets/{automation-DOTL8BFs.js.map → automation-D7g90kTv.js.map} +1 -1
- package/public/assets/{branch-state-badge-BlqeJZ5-.js → branch-state-badge-Bv7DhLBZ.js} +2 -2
- package/public/assets/{branch-state-badge-BlqeJZ5-.js.map → branch-state-badge-Bv7DhLBZ.js.map} +1 -1
- package/public/assets/{channels-BoiKfK6Q.js → channels-CLwPeEPi.js} +2 -2
- package/public/assets/{channels-BoiKfK6Q.js.map → channels-CLwPeEPi.js.map} +1 -1
- package/public/assets/{chat-PeU3iOaa.js → chat-Cgj7VKzc.js} +2 -2
- package/public/assets/{chat-PeU3iOaa.js.map → chat-Cgj7VKzc.js.map} +1 -1
- package/public/assets/{connectors-BvV8Hee2.js → connectors-Br6fiTny.js} +2 -2
- package/public/assets/{connectors-BvV8Hee2.js.map → connectors-Br6fiTny.js.map} +1 -1
- package/public/assets/{formatted-body-C1FgzlT1.js → formatted-body-CmkuD23M.js} +3 -3
- package/public/assets/{formatted-body-C1FgzlT1.js.map → formatted-body-CmkuD23M.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BTpU1xH4.js → formatted-body-impl-DbdFmbwW.js} +2 -2
- package/public/assets/{formatted-body-impl-BTpU1xH4.js.map → formatted-body-impl-DbdFmbwW.js.map} +1 -1
- package/public/assets/{index-DZEahmyc.js → index-D9OogaB5.js} +6 -6
- package/public/assets/{index-DZEahmyc.js.map → index-D9OogaB5.js.map} +1 -1
- package/public/assets/{insights-BqmgSpQm.js → insights-qvaPqWCV.js} +2 -2
- package/public/assets/{insights-BqmgSpQm.js.map → insights-qvaPqWCV.js.map} +1 -1
- package/public/assets/{integrations-CuT92zQi.js → integrations-DYEGSqq_.js} +2 -2
- package/public/assets/{integrations-CuT92zQi.js.map → integrations-DYEGSqq_.js.map} +1 -1
- package/public/assets/{maintenance-lM7FeXVC.js → maintenance-C0HIEB-J.js} +2 -2
- package/public/assets/{maintenance-lM7FeXVC.js.map → maintenance-C0HIEB-J.js.map} +1 -1
- package/public/assets/{managed-agents-DaPNv3yY.js → managed-agents-DdS7aI38.js} +2 -2
- package/public/assets/{managed-agents-DaPNv3yY.js.map → managed-agents-DdS7aI38.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js → markdown-preview-impl-BnXMH1z1.js} +2 -2
- package/public/assets/{markdown-preview-impl-Wia3Ho1s.js.map → markdown-preview-impl-BnXMH1z1.js.map} +1 -1
- package/public/assets/{memory-B1v_zD-M.js → memory-CSwx7bz8.js} +2 -2
- package/public/assets/{memory-B1v_zD-M.js.map → memory-CSwx7bz8.js.map} +1 -1
- package/public/assets/{messages-BNOx3yK6.js → messages-Be9CmvDv.js} +2 -2
- package/public/assets/{messages-BNOx3yK6.js.map → messages-Be9CmvDv.js.map} +1 -1
- package/public/assets/{orchestrators-DWFy4asl.js → orchestrators-BgtvvHvo.js} +2 -2
- package/public/assets/{orchestrators-DWFy4asl.js.map → orchestrators-BgtvvHvo.js.map} +1 -1
- package/public/assets/{overview-C_12cFPM.js → overview-BvhbPWtF.js} +2 -2
- package/public/assets/{overview-C_12cFPM.js.map → overview-BvhbPWtF.js.map} +1 -1
- package/public/assets/{pairs-DIc7gX83.js → pairs-CpXSOMx0.js} +2 -2
- package/public/assets/{pairs-DIc7gX83.js.map → pairs-CpXSOMx0.js.map} +1 -1
- package/public/assets/{projects-DPjvJUzT.js → projects-DdPNPFJI.js} +2 -2
- package/public/assets/{projects-DPjvJUzT.js.map → projects-DdPNPFJI.js.map} +1 -1
- package/public/assets/{prompt-settings-CtumNgG8.js → prompt-settings-BpZse7-X.js} +2 -2
- package/public/assets/{prompt-settings-CtumNgG8.js.map → prompt-settings-BpZse7-X.js.map} +1 -1
- package/public/assets/{registry-DzUhgMq9.js → registry-pb1gTZEg.js} +2 -2
- package/public/assets/{registry-DzUhgMq9.js.map → registry-pb1gTZEg.js.map} +1 -1
- package/public/assets/{security-BJ3_u0uY.js → security-BgcX1n9D.js} +2 -2
- package/public/assets/{security-BJ3_u0uY.js.map → security-BgcX1n9D.js.map} +1 -1
- package/public/assets/{select-AWvlctyQ.js → select-DZPVpKPv.js} +2 -2
- package/public/assets/{select-AWvlctyQ.js.map → select-DZPVpKPv.js.map} +1 -1
- package/public/assets/{settings-5ofqi-bN.js → settings-hd5kzdRB.js} +2 -2
- package/public/assets/{settings-5ofqi-bN.js.map → settings-hd5kzdRB.js.map} +1 -1
- package/public/assets/store-DKTLubBT.js +9 -0
- package/public/assets/store-DKTLubBT.js.map +1 -0
- package/public/assets/{tasks-BEE9bBf7.js → tasks-C5d2LU5E.js} +2 -2
- package/public/assets/{tasks-BEE9bBf7.js.map → tasks-C5d2LU5E.js.map} +1 -1
- package/public/assets/{teams-C27H_Gwo.js → teams-B8f2pGJe.js} +2 -2
- package/public/assets/{teams-C27H_Gwo.js.map → teams-B8f2pGJe.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js → terminal-viewer-impl-CxAscH0U.js} +2 -2
- package/public/assets/{terminal-viewer-impl-WD2TboA7.js.map → terminal-viewer-impl-CxAscH0U.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{work-queue-CtmPK2hK.js → work-queue-9imc7aNK.js} +2 -2
- package/public/assets/{work-queue-CtmPK2hK.js.map → work-queue-9imc7aNK.js.map} +1 -1
- package/public/assets/{workspaces-B26EKv_H.js → workspaces-CLYc3yF3.js} +2 -2
- package/public/assets/{workspaces-B26EKv_H.js.map → workspaces-CLYc3yF3.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -0
- package/src/agent-issue-repo.ts +10 -3
- package/src/agent-lifecycle-events.ts +23 -1
- package/src/agent-ref.ts +18 -3
- package/src/bus-outbox.ts +44 -1
- package/src/bus.ts +28 -5
- package/src/channel-target.ts +4 -0
- package/src/cli/steward.ts +15 -14
- package/src/commands-db.ts +63 -9
- package/src/config.ts +50 -0
- package/src/db/claim-sql.ts +16 -0
- package/src/db/continuations.ts +44 -3
- package/src/db/message-reads.ts +34 -4
- package/src/db/messages.ts +128 -4
- package/src/db/pairs.ts +1 -0
- package/src/db/schema.ts +6 -0
- package/src/gate-resolver.ts +124 -0
- package/src/lifecycle-manager.ts +41 -10
- package/src/lifecycle-signal-registry.ts +87 -0
- package/src/maintenance/constants.ts +5 -1
- package/src/maintenance/jobs.ts +28 -2
- package/src/maintenance/workspaces.ts +75 -44
- package/src/managed-policy-escalation.ts +68 -3
- package/src/mcp/tools-spawn.ts +16 -6
- package/src/mcp-plan-tools.ts +2 -3
- package/src/notification-settings.ts +11 -0
- package/src/notification-types.ts +4 -0
- package/src/operator-alarm.ts +37 -0
- package/src/routes/agents.ts +4 -0
- package/src/routes/orchestrator-proxy.ts +13 -1
- package/src/routes/orchestrator.ts +4 -0
- package/src/security.ts +20 -0
- package/src/self-resume.ts +185 -21
- package/src/server.ts +2 -0
- package/src/services/plan-graph.ts +42 -12
- package/src/services/register-agent.ts +5 -1
- package/src/services/send-message.ts +36 -0
- package/src/services/spawn-agent.ts +21 -2
- package/src/services/task-semantic-events.ts +17 -1
- package/src/spawn-command.ts +3 -0
- package/src/spawn-targets.ts +26 -1
- package/src/sse.ts +13 -5
- package/src/workspace-auto-merge.ts +90 -34
- package/src/workspace-escalation.ts +52 -3
- package/src/workspace-human-escalation.ts +68 -0
- package/src/workspace-orphans.ts +11 -6
- package/src/workspace-phase.ts +22 -1
- package/public/assets/store-D5et8685.js +0 -9
- 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
|
|
101
|
-
if (
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -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
|
-
// #
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
+
// #1033 — LEAN 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
|
-
|
|
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> {
|
package/src/mcp-plan-tools.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -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";
|