agent-relay-server 0.120.1 → 0.120.3
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 +3 -3
- package/public/assets/MessageBubble-Cqi3XsZ7.js +2 -0
- package/public/assets/{MessageBubble-jpshgh3K.js.map → MessageBubble-Cqi3XsZ7.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js → PlanGraphPanel-D6Um05oW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CjVG-Jsq.js.map → PlanGraphPanel-D6Um05oW.js.map} +1 -1
- package/public/assets/{activity-DLTFF8Dz.js → activity-B0LsQHEz.js} +2 -2
- package/public/assets/{activity-DLTFF8Dz.js.map → activity-B0LsQHEz.js.map} +1 -1
- package/public/assets/{agent-profiles-BcCLIrON.js → agent-profiles-O--pe5Ic.js} +2 -2
- package/public/assets/{agent-profiles-BcCLIrON.js.map → agent-profiles-O--pe5Ic.js.map} +1 -1
- package/public/assets/{agent-type-icon-BEz393ra.js → agent-type-icon-CeEoMouY.js} +2 -2
- package/public/assets/{agent-type-icon-BEz393ra.js.map → agent-type-icon-CeEoMouY.js.map} +1 -1
- package/public/assets/agents-Ii6XlVwR.js +2 -0
- package/public/assets/{agents-QemvuxKO.js.map → agents-Ii6XlVwR.js.map} +1 -1
- package/public/assets/{analytics-DMLoMwta.js → analytics-BCzdYWRT.js} +2 -2
- package/public/assets/{analytics-DMLoMwta.js.map → analytics-BCzdYWRT.js.map} +1 -1
- package/public/assets/{automation-DACSOffp.js → automation-Crr3Xzui.js} +2 -2
- package/public/assets/{automation-DACSOffp.js.map → automation-Crr3Xzui.js.map} +1 -1
- package/public/assets/{badge-BhUZ1cAb.js → badge-DCIT3Irj.js} +2 -2
- package/public/assets/{badge-BhUZ1cAb.js.map → badge-DCIT3Irj.js.map} +1 -1
- package/public/assets/{branch-state-badge-BYE2U_y4.js → branch-state-badge-Bhw2rloy.js} +2 -2
- package/public/assets/{branch-state-badge-BYE2U_y4.js.map → branch-state-badge-Bhw2rloy.js.map} +1 -1
- package/public/assets/{button-C-V4oij1.js → button-CzdTkRIf.js} +2 -2
- package/public/assets/{button-C-V4oij1.js.map → button-CzdTkRIf.js.map} +1 -1
- package/public/assets/{card-Br1JLjTH.js → card-C3BGuVC6.js} +2 -2
- package/public/assets/{card-Br1JLjTH.js.map → card-C3BGuVC6.js.map} +1 -1
- package/public/assets/{channels-3SlDQL3j.js → channels-CKs9rvsn.js} +2 -2
- package/public/assets/{channels-3SlDQL3j.js.map → channels-CKs9rvsn.js.map} +1 -1
- package/public/assets/chat-BCwEUj5R.js +2 -0
- package/public/assets/chat-BCwEUj5R.js.map +1 -0
- package/public/assets/{connectors-CrEW591Q.js → connectors-D_kQR_vw.js} +2 -2
- package/public/assets/{connectors-CrEW591Q.js.map → connectors-D_kQR_vw.js.map} +1 -1
- package/public/assets/{copy-button-BStzXgtd.js → copy-button-B3o1jpLu.js} +2 -2
- package/public/assets/{copy-button-BStzXgtd.js.map → copy-button-B3o1jpLu.js.map} +1 -1
- package/public/assets/{dist-ChDX_t12.js → dist-B-T7lg0a.js} +2 -2
- package/public/assets/{dist-ChDX_t12.js.map → dist-B-T7lg0a.js.map} +1 -1
- package/public/assets/{dist-DV83j9tm.js → dist-B3e4B9pg.js} +2 -2
- package/public/assets/{dist-DV83j9tm.js.map → dist-B3e4B9pg.js.map} +1 -1
- package/public/assets/{dist-y03IwOrd.js → dist-B6woQ9Tb.js} +2 -2
- package/public/assets/{dist-y03IwOrd.js.map → dist-B6woQ9Tb.js.map} +1 -1
- package/public/assets/{dist-CQ0Z-TDi.js → dist-B9G83DXv.js} +2 -2
- package/public/assets/{dist-CQ0Z-TDi.js.map → dist-B9G83DXv.js.map} +1 -1
- package/public/assets/{dist-Cs8X0P1d.js → dist-BIjlN-xB.js} +2 -2
- package/public/assets/{dist-Cs8X0P1d.js.map → dist-BIjlN-xB.js.map} +1 -1
- package/public/assets/{dist-DyNJNwpi.js → dist-BInckSJC.js} +2 -2
- package/public/assets/{dist-DyNJNwpi.js.map → dist-BInckSJC.js.map} +1 -1
- package/public/assets/{dist-CmFeEXMe.js → dist-BlzVGRhh.js} +2 -2
- package/public/assets/{dist-CmFeEXMe.js.map → dist-BlzVGRhh.js.map} +1 -1
- package/public/assets/{dist-CDZZxxJq.js → dist-C7aTlosb.js} +2 -2
- package/public/assets/{dist-CDZZxxJq.js.map → dist-C7aTlosb.js.map} +1 -1
- package/public/assets/{dist-D2jvo5oA.js → dist-DGfp5dLz.js} +2 -2
- package/public/assets/{dist-D2jvo5oA.js.map → dist-DGfp5dLz.js.map} +1 -1
- package/public/assets/{dist-mm7c7nBt.js → dist-DT-OV6sQ.js} +2 -2
- package/public/assets/{dist-mm7c7nBt.js.map → dist-DT-OV6sQ.js.map} +1 -1
- package/public/assets/{dist-CBMWGhWc.js → dist-OUPTEP6q.js} +2 -2
- package/public/assets/{dist-CBMWGhWc.js.map → dist-OUPTEP6q.js.map} +1 -1
- package/public/assets/{es2015-VnZ2kVIz.js → es2015-CnKXYKpf.js} +2 -2
- package/public/assets/{es2015-VnZ2kVIz.js.map → es2015-CnKXYKpf.js.map} +1 -1
- package/public/assets/formatted-body-impl-CSRGuMcM.js +3 -0
- package/public/assets/formatted-body-impl-CSRGuMcM.js.map +1 -0
- package/public/assets/formatted-body-q_qzt1L-.js +3 -0
- package/public/assets/{formatted-body-Dg9R3RD7.js.map → formatted-body-q_qzt1L-.js.map} +1 -1
- package/public/assets/{index-D_fNJnsI.js → index-Ceiwd02X.js} +10 -10
- package/public/assets/index-Ceiwd02X.js.map +1 -0
- package/public/assets/{input-CtLT7WXi.js → input-B6Q0NWCS.js} +2 -2
- package/public/assets/{input-CtLT7WXi.js.map → input-B6Q0NWCS.js.map} +1 -1
- package/public/assets/{insights-D48n2ojF.js → insights-DUhXqZTZ.js} +2 -2
- package/public/assets/{insights-D48n2ojF.js.map → insights-DUhXqZTZ.js.map} +1 -1
- package/public/assets/{integrations-B-bdliwU.js → integrations-lbg5CjtE.js} +2 -2
- package/public/assets/{integrations-B-bdliwU.js.map → integrations-lbg5CjtE.js.map} +1 -1
- package/public/assets/{lib-ByLQqcD1.js → lib-BHsWPMKB.js} +2 -2
- package/public/assets/{lib-ByLQqcD1.js.map → lib-BHsWPMKB.js.map} +1 -1
- package/public/assets/lucide-react-DuhlF2Sj.js +10 -0
- package/public/assets/lucide-react-DuhlF2Sj.js.map +1 -0
- package/public/assets/{maintenance-LQ_U3Nqy.js → maintenance-CQgneSVf.js} +2 -2
- package/public/assets/{maintenance-LQ_U3Nqy.js.map → maintenance-CQgneSVf.js.map} +1 -1
- package/public/assets/{managed-agents-Cfh8FvY8.js → managed-agents-DsnYf6fm.js} +4 -4
- package/public/assets/{managed-agents-Cfh8FvY8.js.map → managed-agents-DsnYf6fm.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js → markdown-preview-impl-C1ZtGJp6.js} +2 -2
- package/public/assets/{markdown-preview-impl-BSa7zYx1.js.map → markdown-preview-impl-C1ZtGJp6.js.map} +1 -1
- package/public/assets/{memory-Dr1iYKkC.js → memory-SSLUTOI2.js} +2 -2
- package/public/assets/{memory-Dr1iYKkC.js.map → memory-SSLUTOI2.js.map} +1 -1
- package/public/assets/messages-BY26OP4K.js +2 -0
- package/public/assets/{messages-DRKulS-2.js.map → messages-BY26OP4K.js.map} +1 -1
- package/public/assets/{orchestrators-BguyeViG.js → orchestrators-OY7yPzVI.js} +2 -2
- package/public/assets/{orchestrators-BguyeViG.js.map → orchestrators-OY7yPzVI.js.map} +1 -1
- package/public/assets/overview-Dj0u1tPG.js +2 -0
- package/public/assets/{overview-Dbq2lr7Y.js.map → overview-Dj0u1tPG.js.map} +1 -1
- package/public/assets/{pairs-DD7i2scF.js → pairs-CrUR50ou.js} +2 -2
- package/public/assets/{pairs-DD7i2scF.js.map → pairs-CrUR50ou.js.map} +1 -1
- package/public/assets/{projects-DEn85LQb.js → projects-B_AtxHtH.js} +2 -2
- package/public/assets/{projects-DEn85LQb.js.map → projects-B_AtxHtH.js.map} +1 -1
- package/public/assets/{prompt-settings-DwgrPKY3.js → prompt-settings-CPtYSban.js} +2 -2
- package/public/assets/{prompt-settings-DwgrPKY3.js.map → prompt-settings-CPtYSban.js.map} +1 -1
- package/public/assets/{react-dom-cLkVFBsS.js → react-dom-DWBmcawP.js} +2 -2
- package/public/assets/{react-dom-cLkVFBsS.js.map → react-dom-DWBmcawP.js.map} +1 -1
- package/public/assets/{registry-DCR3xFpe.js → registry-BgUALWn5.js} +2 -2
- package/public/assets/{registry-DCR3xFpe.js.map → registry-BgUALWn5.js.map} +1 -1
- package/public/assets/security-5lHfGk7g.js +3 -0
- package/public/assets/security-5lHfGk7g.js.map +1 -0
- package/public/assets/{select-CpyCRBK3.js → select-Cgx4QSLj.js} +2 -2
- package/public/assets/{select-CpyCRBK3.js.map → select-Cgx4QSLj.js.map} +1 -1
- package/public/assets/settings-DFGgxyun.js +6 -0
- package/public/assets/{settings-DYNFGC2-.js.map → settings-DFGgxyun.js.map} +1 -1
- package/public/assets/store-BM4zM89B.js +9 -0
- package/public/assets/store-BM4zM89B.js.map +1 -0
- package/public/assets/{task-chip-kxk4eNm1.js → task-chip-DXfvXBDx.js} +2 -2
- package/public/assets/{task-chip-kxk4eNm1.js.map → task-chip-DXfvXBDx.js.map} +1 -1
- package/public/assets/{tasks-rdy9Nsbf.js → tasks-BvH5rluu.js} +2 -2
- package/public/assets/{tasks-rdy9Nsbf.js.map → tasks-BvH5rluu.js.map} +1 -1
- package/public/assets/{teams-DHloBagJ.js → teams-VZOIOdoE.js} +2 -2
- package/public/assets/{teams-DHloBagJ.js.map → teams-VZOIOdoE.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js → terminal-viewer-impl-DYY3Wmoe.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DWTuNejR.js.map → terminal-viewer-impl-DYY3Wmoe.js.map} +1 -1
- package/public/assets/types-uVOXgvMT.js.map +1 -1
- package/public/assets/{use-keyboard-viewport-B06XtRUZ.js → use-keyboard-viewport-CP109w9y.js} +2 -2
- package/public/assets/{use-keyboard-viewport-B06XtRUZ.js.map → use-keyboard-viewport-CP109w9y.js.map} +1 -1
- package/public/assets/{user-avatar-DuwCFrER.js → user-avatar-Dw6mzWhv.js} +2 -2
- package/public/assets/{user-avatar-DuwCFrER.js.map → user-avatar-Dw6mzWhv.js.map} +1 -1
- package/public/assets/{work-queue-CShT7nTC.js → work-queue-B6Xk7QNe.js} +2 -2
- package/public/assets/{work-queue-CShT7nTC.js.map → work-queue-B6Xk7QNe.js.map} +1 -1
- package/public/assets/{workspaces-DjQ6EdrZ.js → workspaces-CPIzAHAe.js} +2 -2
- package/public/assets/{workspaces-DjQ6EdrZ.js.map → workspaces-CPIzAHAe.js.map} +1 -1
- package/public/index.html +23 -23
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/automations/reconcile.ts +69 -4
- package/src/branch-landed.ts +18 -11
- package/src/bus.ts +25 -18
- package/src/db/connection.ts +53 -1
- package/src/db/maintenance-worker.ts +37 -0
- package/src/db/message-reads.ts +86 -45
- package/src/db/migrations.ts +1 -0
- package/src/db/provider-quotas.ts +6 -7
- package/src/db/schema.ts +2 -1
- package/src/maintenance/jobs.ts +19 -13
- package/src/maintenance/scheduler.ts +8 -3
- package/src/maintenance/types.ts +5 -1
- package/src/mcp/index.ts +5 -5
- package/src/mcp/tools-workspace.ts +8 -3
- package/src/routes/orchestrator-proxy.ts +7 -6
- package/src/routes/sse.ts +2 -1
- package/src/routes/tasks.ts +29 -0
- package/src/routes/workspaces.ts +9 -2
- package/src/runtime-tokens.ts +4 -0
- package/src/security.ts +31 -7
- package/src/server.ts +40 -18
- package/src/services/git-process.ts +61 -0
- package/src/services/git-remote.ts +87 -7
- package/src/services/issue-lifecycle.ts +6 -6
- package/src/services/project.ts +47 -1
- package/src/services/send-message.ts +2 -4
- package/src/services/spawn-agent.ts +2 -2
- package/src/services/task-lifecycle.ts +6 -9
- package/src/settings/registry.ts +6 -4
- package/src/spawn-targets.ts +2 -2
- package/src/sse.ts +131 -46
- package/src/steward.ts +10 -6
- package/src/terminal-authz.ts +50 -0
- package/src/token-db.ts +3 -3
- package/src/validation.ts +1 -1
- package/src/workspace-actions.ts +46 -4
- package/src/workspace-auto-merge.ts +120 -13
- package/src/workspace-orphans.ts +1 -1
- package/src/workspace-probe.ts +19 -10
- package/public/assets/MessageBubble-jpshgh3K.js +0 -2
- package/public/assets/agents-QemvuxKO.js +0 -2
- package/public/assets/chat-xKXCafN9.js +0 -2
- package/public/assets/chat-xKXCafN9.js.map +0 -1
- package/public/assets/formatted-body-Dg9R3RD7.js +0 -3
- package/public/assets/formatted-body-impl-DlasTD8X.js +0 -3
- package/public/assets/formatted-body-impl-DlasTD8X.js.map +0 -1
- package/public/assets/index-D_fNJnsI.js.map +0 -1
- package/public/assets/lucide-react-Dc3ZwCBi.js +0 -9
- package/public/assets/lucide-react-Dc3ZwCBi.js.map +0 -1
- package/public/assets/messages-DRKulS-2.js +0 -2
- package/public/assets/overview-Dbq2lr7Y.js +0 -2
- package/public/assets/security-BH8kTdA-.js +0 -3
- package/public/assets/security-BH8kTdA-.js.map +0 -1
- package/public/assets/settings-DYNFGC2-.js +0 -6
- package/public/assets/store-BuuJUn70.js +0 -9
- package/public/assets/store-BuuJUn70.js.map +0 -1
|
@@ -125,10 +125,15 @@ function supersededAccountKeys(provider: string, accountKey: string, rawAccountK
|
|
|
125
125
|
return [...candidates];
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
// Source priority for quota precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
|
|
129
|
+
const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
|
|
130
|
+
function quotaSourcePriority(source: string): number { return QUOTA_SOURCE_PRIORITY[source] ?? 1; }
|
|
131
|
+
|
|
128
132
|
function newestQuotaState(rows: ProviderQuotaRow[]): string | null {
|
|
133
|
+
const priority = (candidate: ProviderQuotaRow) => { const source = parseJson<Partial<QuotaState>>(candidate.quota_state, { source: "probe" }).source; return quotaSourcePriority(typeof source === "string" ? source : "probe"); };
|
|
129
134
|
const row = rows
|
|
130
135
|
.filter((candidate) => candidate.quota_state)
|
|
131
|
-
.sort((a, b) => (b.last_updated_at ?? 0) - (a.last_updated_at ?? 0))[0];
|
|
136
|
+
.sort((a, b) => priority(b) - priority(a) || (b.last_updated_at ?? 0) - (a.last_updated_at ?? 0))[0];
|
|
132
137
|
return row?.quota_state ?? null;
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -285,12 +290,6 @@ function storedQuotaWindowSampleTimes(quota: StoredQuotaState): Map<string, numb
|
|
|
285
290
|
return times;
|
|
286
291
|
}
|
|
287
292
|
|
|
288
|
-
// Source priority for per-window precedence: "ingest" (WHAM/API) beats "probe" (agent self-report).
|
|
289
|
-
const QUOTA_SOURCE_PRIORITY: Record<string, number> = { ingest: 3, header: 2, probe: 1, statusline: 0, emulated: -1 };
|
|
290
|
-
function quotaSourcePriority(source: string): number {
|
|
291
|
-
return QUOTA_SOURCE_PRIORITY[source] ?? 1;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
293
|
function storedQuotaWindowSources(quota: StoredQuotaState): Map<string, string> {
|
|
295
294
|
const sources = new Map<string, string>();
|
|
296
295
|
const rawSources = quota._windowSource && typeof quota._windowSource === "object" ? quota._windowSource : {};
|
package/src/db/schema.ts
CHANGED
|
@@ -91,7 +91,7 @@ import type {
|
|
|
91
91
|
export function initDb(path: string = "agent-relay.db"): Database {
|
|
92
92
|
const conn = new Database(path, { create: true });
|
|
93
93
|
applyConnectionPragmas(conn);
|
|
94
|
-
setDb(conn);
|
|
94
|
+
setDb(conn, path);
|
|
95
95
|
|
|
96
96
|
getDb().run(`
|
|
97
97
|
CREATE TABLE IF NOT EXISTS agents (
|
|
@@ -165,6 +165,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
|
|
|
165
165
|
-- resolved_to_agent ALTER — never inline here (same hazard as #483/#559).
|
|
166
166
|
-- idx_msg_reply_to is also migration-owned because reply_to was added by ALTER.
|
|
167
167
|
CREATE INDEX IF NOT EXISTS idx_msg_to_id ON messages(to_target, id);
|
|
168
|
+
CREATE INDEX IF NOT EXISTS idx_msg_from_id ON messages(from_agent, id);
|
|
168
169
|
CREATE INDEX IF NOT EXISTS idx_msg_created ON messages(created_at);
|
|
169
170
|
CREATE INDEX IF NOT EXISTS idx_msg_channel ON messages(channel);
|
|
170
171
|
CREATE TABLE IF NOT EXISTS scheduled_timers (
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -13,14 +13,14 @@ import {
|
|
|
13
13
|
listStaleQueuedDirectTargets,
|
|
14
14
|
pruneOrphanedSharedWorkspaces,
|
|
15
15
|
pruneOfflineAgents,
|
|
16
|
-
|
|
16
|
+
pruneOldMessagesAsync,
|
|
17
17
|
pruneProviderQuotaHistory,
|
|
18
18
|
reapStaleAgents,
|
|
19
19
|
reapStaleOrchestrators,
|
|
20
20
|
releaseExpiredClaims,
|
|
21
21
|
releaseExpiredDriveLeases,
|
|
22
22
|
releaseOrphanedTasks,
|
|
23
|
-
|
|
23
|
+
runDbMaintenanceAsync,
|
|
24
24
|
sweepArtifacts,
|
|
25
25
|
} from "../db";
|
|
26
26
|
import { isAgentUnavailable } from "../db/agent-predicates";
|
|
@@ -72,6 +72,10 @@ import type { MaintenanceJobDefinition } from "./types";
|
|
|
72
72
|
|
|
73
73
|
let dbMaintenanceRuns = 0;
|
|
74
74
|
|
|
75
|
+
function isFleetIdleForVacuum(): boolean {
|
|
76
|
+
return !listAgents({ status: "busy" }).some((agent) => agent.kind === "provider" && agent.id !== "user" && agent.id !== "system");
|
|
77
|
+
}
|
|
78
|
+
|
|
75
79
|
function isManagedBusyWatchCandidate(agent: ReturnType<typeof listAgents>[number]): boolean {
|
|
76
80
|
if (agent.kind !== "provider") return false;
|
|
77
81
|
if (agent.status !== "busy") return false;
|
|
@@ -315,17 +319,17 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
315
319
|
description: "Stop spawned sessions whose relay agent is offline/gone but whose process the orchestrator still reports running, after a grace period for self-heal.",
|
|
316
320
|
intervalMs: ORPHAN_REAPER_INTERVAL_MS,
|
|
317
321
|
runOnStart: false,
|
|
318
|
-
handler: reapOrphanedSessions,
|
|
322
|
+
handler: () => reapOrphanedSessions(),
|
|
319
323
|
},
|
|
320
|
-
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: reapTransientAgents },
|
|
321
|
-
{ id: "rate-limit-resume", title: "Rate-limit resume", description: "Auto-resume agents held on a provider usage/rate-limit stall once their reset window has passed (#286).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: resumeRateLimitedAgents },
|
|
324
|
+
{ id: "transient-agent-reaper", title: "Transient agent reaper", description: "Cleanly shut down transient spawned agents once they are idle and any isolated branch work has landed.", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => reapTransientAgents() },
|
|
325
|
+
{ id: "rate-limit-resume", title: "Rate-limit resume", description: "Auto-resume agents held on a provider usage/rate-limit stall once their reset window has passed (#286).", intervalMs: REAP_INTERVAL_MS, runOnStart: false, handler: () => resumeRateLimitedAgents() },
|
|
322
326
|
{
|
|
323
327
|
id: "stuck-busy-watchdog",
|
|
324
328
|
title: "Stuck busy watchdog",
|
|
325
329
|
description: "Warn when a managed provider stays busy past the threshold without provider progress (#53).",
|
|
326
330
|
intervalMs: REAP_INTERVAL_MS,
|
|
327
331
|
runOnStart: false,
|
|
328
|
-
handler: runStuckBusyWatchdog,
|
|
332
|
+
handler: () => runStuckBusyWatchdog(),
|
|
329
333
|
},
|
|
330
334
|
{
|
|
331
335
|
id: "team-reap",
|
|
@@ -341,7 +345,7 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
341
345
|
description: "Gently ping a team's current coordinator once when the team has had no busy members for the configured threshold.",
|
|
342
346
|
intervalMs: REAP_INTERVAL_MS,
|
|
343
347
|
runOnStart: false,
|
|
344
|
-
handler: runTeamIdleWatchdog,
|
|
348
|
+
handler: () => runTeamIdleWatchdog(),
|
|
345
349
|
},
|
|
346
350
|
{
|
|
347
351
|
id: "orchestrator-reaper",
|
|
@@ -373,9 +377,9 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
373
377
|
description: "Delete messages outside the configured retention window.",
|
|
374
378
|
intervalMs: DAY_MS,
|
|
375
379
|
runOnStart: false,
|
|
376
|
-
handler() {
|
|
380
|
+
handler(context) {
|
|
377
381
|
const retentionDays = Number(process.env.RETENTION_DAYS) || 30;
|
|
378
|
-
return
|
|
382
|
+
return pruneOldMessagesAsync(retentionDays * DAY_MS, { signal: context?.signal });
|
|
379
383
|
},
|
|
380
384
|
},
|
|
381
385
|
{
|
|
@@ -385,10 +389,12 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
385
389
|
intervalMs: DB_MAINTENANCE_INTERVAL_MS,
|
|
386
390
|
runOnStart: false,
|
|
387
391
|
timeoutMs: 5 * 60 * 1000,
|
|
388
|
-
handler() {
|
|
392
|
+
async handler(context) {
|
|
389
393
|
dbMaintenanceRuns += 1;
|
|
390
|
-
const
|
|
391
|
-
|
|
394
|
+
const vacuumDue = DB_VACUUM_EVERY > 0 && dbMaintenanceRuns % DB_VACUUM_EVERY === 0;
|
|
395
|
+
const vacuum = vacuumDue && isFleetIdleForVacuum();
|
|
396
|
+
const result = await runDbMaintenanceAsync({ vacuum, signal: context?.signal });
|
|
397
|
+
return vacuumDue && !vacuum ? { ...result, vacuumSkipped: "fleet-busy" } : result;
|
|
392
398
|
},
|
|
393
399
|
},
|
|
394
400
|
{
|
|
@@ -492,7 +498,7 @@ export const definitions: MaintenanceJobDefinition[] = [
|
|
|
492
498
|
intervalMs: 60 * 1000,
|
|
493
499
|
runOnStart: false,
|
|
494
500
|
timeoutMs: 10 * 1000,
|
|
495
|
-
handler: runAutoMergeWatchdog,
|
|
501
|
+
handler: () => runAutoMergeWatchdog(),
|
|
496
502
|
},
|
|
497
503
|
{
|
|
498
504
|
id: "workspace-idle-refresh",
|
|
@@ -118,8 +118,9 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
|
|
|
118
118
|
return { id: definition.id, status: "skipped", startedAt, finishedAt: Date.now(), durationMs: 0, result: { reason: "not due or already running", state } };
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
const abortController = new AbortController();
|
|
121
122
|
try {
|
|
122
|
-
const result = await withTimeout(Promise.resolve(definition.handler()), timeoutMs);
|
|
123
|
+
const result = await withTimeout(Promise.resolve(definition.handler({ signal: abortController.signal })), timeoutMs, abortController);
|
|
123
124
|
const finishedAt = Date.now();
|
|
124
125
|
const durationMs = finishedAt - startedAt;
|
|
125
126
|
db.query(`
|
|
@@ -145,10 +146,14 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
|
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
149
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, abortController?: AbortController): Promise<T> {
|
|
149
150
|
let timeout: Timer | undefined;
|
|
150
151
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
151
|
-
timeout = setTimeout(() =>
|
|
152
|
+
timeout = setTimeout(() => {
|
|
153
|
+
const error = new Error(`maintenance job timed out after ${timeoutMs}ms`);
|
|
154
|
+
abortController?.abort(error);
|
|
155
|
+
reject(error);
|
|
156
|
+
}, timeoutMs);
|
|
152
157
|
});
|
|
153
158
|
try {
|
|
154
159
|
return await Promise.race([promise, timeoutPromise]);
|
package/src/maintenance/types.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { MaintenanceJob } from "../types";
|
|
2
2
|
|
|
3
|
+
export interface MaintenanceJobContext {
|
|
4
|
+
signal: AbortSignal;
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
export interface MaintenanceJobDefinition {
|
|
4
8
|
id: string;
|
|
5
9
|
title: string;
|
|
@@ -7,7 +11,7 @@ export interface MaintenanceJobDefinition {
|
|
|
7
11
|
intervalMs: number;
|
|
8
12
|
timeoutMs?: number;
|
|
9
13
|
runOnStart?: boolean;
|
|
10
|
-
handler: () => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
14
|
+
handler: (context?: MaintenanceJobContext) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
export interface MaintenanceJobRow {
|
package/src/mcp/index.ts
CHANGED
|
@@ -167,11 +167,11 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
167
167
|
else if (name === "relay_agent_action") result = relayAgentAction(auth, args);
|
|
168
168
|
else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
|
|
169
169
|
else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
|
|
170
|
-
else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
|
|
171
|
-
else if (name === "relay_workspace_deps") result = relayWorkspaceMutation(auth, "deps-refresh", args);
|
|
172
|
-
else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
|
|
173
|
-
else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
|
|
174
|
-
else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
|
|
170
|
+
else if (name === "relay_workspace_ready") result = await relayWorkspaceMutation(auth, "ready", args);
|
|
171
|
+
else if (name === "relay_workspace_deps") result = await relayWorkspaceMutation(auth, "deps-refresh", args);
|
|
172
|
+
else if (name === "relay_workspace_claim") result = await relayWorkspaceMutation(auth, "claim", args);
|
|
173
|
+
else if (name === "relay_workspace_release") result = await relayWorkspaceMutation(auth, "release-claim", args);
|
|
174
|
+
else if (name === "relay_workspace_land") result = await relayWorkspaceMutation(auth, "merge", args);
|
|
175
175
|
else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
|
|
176
176
|
else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
|
|
177
177
|
else if (name.startsWith("relay_task_memory_")) result = relayTaskMemoryTool(auth, name, args);
|
|
@@ -4,6 +4,7 @@ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
|
4
4
|
import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
5
5
|
import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
|
|
6
6
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
|
|
7
|
+
import { refreshWorkspaceGitState } from "../workspace-probe";
|
|
7
8
|
import { optionalEnum } from "../validation";
|
|
8
9
|
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
|
|
9
10
|
import type { McpAuthContext, ToolDefinition } from "./types";
|
|
@@ -185,9 +186,13 @@ export function relayWorkspaceList(auth: McpAuthContext, args: Record<string, un
|
|
|
185
186
|
return { workspaces, count: workspaces.length };
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
export function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Record<string, unknown
|
|
189
|
-
|
|
190
|
-
|
|
189
|
+
export async function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
190
|
+
let ws = resolveWorkspaceForCaller(auth, args);
|
|
191
|
+
if (action === "ready" || action === "request-review") {
|
|
192
|
+
await refreshWorkspaceGitState(ws);
|
|
193
|
+
ws = getWorkspace(ws.id) ?? ws;
|
|
194
|
+
}
|
|
195
|
+
const result = await applyWorkspaceAction(ws, {
|
|
191
196
|
action,
|
|
192
197
|
agentId: callerAgentId(auth) ?? auth.actor,
|
|
193
198
|
detail: optionalString(args.detail, "detail", 4000),
|
|
@@ -6,6 +6,7 @@ import { cleanStringArray } from "../validation";
|
|
|
6
6
|
import { emitOrchestratorStatus } from "../sse";
|
|
7
7
|
import { type OrchestratorRuntimeInput, type SpawnProvider } from "../types";
|
|
8
8
|
import { relayToken } from "../config";
|
|
9
|
+
import { isTerminalSessionRequestAuthorized } from "../terminal-authz";
|
|
9
10
|
|
|
10
11
|
export const getOrchestratorDirectories: Handler = async (req, params) => {
|
|
11
12
|
const orch = getOrchestrator(params.id!);
|
|
@@ -146,16 +147,16 @@ export const getOrchestratorLogs: Handler = (req, params) => {
|
|
|
146
147
|
};
|
|
147
148
|
|
|
148
149
|
export const getOrchestratorTerminal: Handler = (req, params) => {
|
|
149
|
-
|
|
150
|
-
return
|
|
150
|
+
if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "read")) return error("forbidden", 403);
|
|
151
|
+
return proxyOrchestratorGet(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}`);
|
|
151
152
|
};
|
|
152
153
|
|
|
153
154
|
export const postOrchestratorTerminalInput: Handler = (req, params) => {
|
|
154
|
-
|
|
155
|
-
return
|
|
155
|
+
if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "write")) return error("forbidden", 403);
|
|
156
|
+
return proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/input`);
|
|
156
157
|
};
|
|
157
158
|
|
|
158
159
|
export const postOrchestratorTerminalResize: Handler = (req, params) => {
|
|
159
|
-
|
|
160
|
-
return
|
|
160
|
+
if (!isTerminalSessionRequestAuthorized(req, params.id!, params.session!, "read")) return error("forbidden", 403);
|
|
161
|
+
return proxyOrchestratorPost(req, params.id!, `/api/terminal/${encodeURIComponent(params.session!)}/resize`);
|
|
161
162
|
};
|
package/src/routes/sse.ts
CHANGED
|
@@ -5,5 +5,6 @@ import { type Handler } from "./_shared";
|
|
|
5
5
|
export const getEvents: Handler = (req) => {
|
|
6
6
|
const url = new URL(req.url);
|
|
7
7
|
const agentId = url.searchParams.get("for") || null;
|
|
8
|
-
|
|
8
|
+
const lastEventId = Number(req.headers.get("last-event-id") ?? "");
|
|
9
|
+
return createSSEStream(agentId, Number.isSafeInteger(lastEventId) && lastEventId > 0 ? lastEventId : 0);
|
|
9
10
|
};
|
package/src/routes/tasks.ts
CHANGED
|
@@ -29,6 +29,15 @@ function taskDetailOr404(id: number) {
|
|
|
29
29
|
return detail ? json(detail) : error("deliverable task not found", 404);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
function requireTaskMutationAuth(req: Request, id: number): Response | null {
|
|
33
|
+
if (!getTaskEntity(id)) return error("deliverable task not found", 404);
|
|
34
|
+
if (isFullReachRequest(req)) return null;
|
|
35
|
+
const component = getComponentAuth(req);
|
|
36
|
+
const agentId = component ? agentIdFromComponent(component) : undefined;
|
|
37
|
+
if (!isTaskReadAuthorized(id, agentId, component?.scope ?? [])) return error("forbidden", 403);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
|
|
33
42
|
if (!isRecord(body)) throw new ValidationError("JSON object body required");
|
|
34
43
|
const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
|
|
@@ -173,6 +182,8 @@ export const getTaskHistoryRoute: Handler = (req, params) => {
|
|
|
173
182
|
export const patchTaskEntity: Handler = async (req, params) => {
|
|
174
183
|
const id = parseId(params.id);
|
|
175
184
|
if (id === null) return error("invalid task id");
|
|
185
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
186
|
+
if (forbidden) return forbidden;
|
|
176
187
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
177
188
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
178
189
|
const body = parsed.body;
|
|
@@ -200,6 +211,8 @@ export const patchTaskEntity: Handler = async (req, params) => {
|
|
|
200
211
|
export const postTaskEntityStatus: Handler = async (req, params) => {
|
|
201
212
|
const id = parseId(params.id);
|
|
202
213
|
if (id === null) return error("invalid task id");
|
|
214
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
215
|
+
if (forbidden) return forbidden;
|
|
203
216
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
204
217
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
205
218
|
const body = parsed.body;
|
|
@@ -220,6 +233,8 @@ export const postTaskEntityStatus: Handler = async (req, params) => {
|
|
|
220
233
|
export const postTaskEntityStage: Handler = async (req, params) => {
|
|
221
234
|
const id = parseId(params.id);
|
|
222
235
|
if (id === null) return error("invalid task id");
|
|
236
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
237
|
+
if (forbidden) return forbidden;
|
|
223
238
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
224
239
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
225
240
|
const body = parsed.body;
|
|
@@ -240,6 +255,8 @@ export const postTaskEntityStage: Handler = async (req, params) => {
|
|
|
240
255
|
export const postTaskEntityGate: Handler = async (req, params) => {
|
|
241
256
|
const id = parseId(params.id);
|
|
242
257
|
if (id === null) return error("invalid task id");
|
|
258
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
259
|
+
if (forbidden) return forbidden;
|
|
243
260
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
244
261
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
245
262
|
const body = parsed.body;
|
|
@@ -261,6 +278,8 @@ export const postTaskEntityGate: Handler = async (req, params) => {
|
|
|
261
278
|
export const postTaskDependency: Handler = async (req, params) => {
|
|
262
279
|
const id = parseId(params.id);
|
|
263
280
|
if (id === null) return error("invalid task id");
|
|
281
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
282
|
+
if (forbidden) return forbidden;
|
|
264
283
|
const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
|
|
265
284
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
266
285
|
const dep = parseId(String(parsed.body?.dependsOnTaskId));
|
|
@@ -276,6 +295,8 @@ export const postTaskDependency: Handler = async (req, params) => {
|
|
|
276
295
|
export const deleteTaskDependency: Handler = async (req, params) => {
|
|
277
296
|
const id = parseId(params.id);
|
|
278
297
|
if (id === null) return error("invalid task id");
|
|
298
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
299
|
+
if (forbidden) return forbidden;
|
|
279
300
|
const parsed = await parseBody<{ dependsOnTaskId?: number }>(req);
|
|
280
301
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
281
302
|
const dep = parseId(String(parsed.body?.dependsOnTaskId));
|
|
@@ -287,6 +308,8 @@ export const deleteTaskDependency: Handler = async (req, params) => {
|
|
|
287
308
|
export const postTaskRef: Handler = async (req, params) => {
|
|
288
309
|
const id = parseId(params.id);
|
|
289
310
|
if (id === null) return error("invalid task id");
|
|
311
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
312
|
+
if (forbidden) return forbidden;
|
|
290
313
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
291
314
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
292
315
|
const body = parsed.body;
|
|
@@ -308,6 +331,8 @@ export const postTaskRef: Handler = async (req, params) => {
|
|
|
308
331
|
export const deleteTaskRef: Handler = async (req, params) => {
|
|
309
332
|
const id = parseId(params.id);
|
|
310
333
|
if (id === null) return error("invalid task id");
|
|
334
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
335
|
+
if (forbidden) return forbidden;
|
|
311
336
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
312
337
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
313
338
|
const body = parsed.body;
|
|
@@ -328,6 +353,8 @@ export const deleteTaskRef: Handler = async (req, params) => {
|
|
|
328
353
|
export const postTaskAssign: Handler = async (req, params) => {
|
|
329
354
|
const id = parseId(params.id);
|
|
330
355
|
if (id === null) return error("invalid task id");
|
|
356
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
357
|
+
if (forbidden) return forbidden;
|
|
331
358
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
332
359
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
333
360
|
const body = parsed.body;
|
|
@@ -345,6 +372,8 @@ export const postTaskAssign: Handler = async (req, params) => {
|
|
|
345
372
|
export const postTaskComment: Handler = async (req, params) => {
|
|
346
373
|
const id = parseId(params.id);
|
|
347
374
|
if (id === null) return error("invalid task id");
|
|
375
|
+
const forbidden = requireTaskMutationAuth(req, id);
|
|
376
|
+
if (forbidden) return forbidden;
|
|
348
377
|
const parsed = await parseBody<Record<string, unknown>>(req);
|
|
349
378
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
350
379
|
const body = parsed.body;
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { workspaceActiveClaim } from "../workspace-claim";
|
|
|
16
16
|
import { resolveCoordinatorAgentId } from "../steward-identity";
|
|
17
17
|
import { relayToken } from "../config";
|
|
18
18
|
import { workspaceOwnerOrchestrator } from "../workspace-host";
|
|
19
|
+
import { refreshWorkspaceGitState } from "../workspace-probe";
|
|
19
20
|
|
|
20
21
|
export const getWorkspaces: Handler = (req) => {
|
|
21
22
|
try {
|
|
@@ -423,7 +424,7 @@ export const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
423
424
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
424
425
|
try {
|
|
425
426
|
if (!isRecord(parsed.body)) return error("body required");
|
|
426
|
-
|
|
427
|
+
let workspace = getWorkspace(params.id!);
|
|
427
428
|
if (!workspace) return error("workspace not found", 404);
|
|
428
429
|
const action = optionalEnum(parsed.body.action, "action", WORKSPACE_ACTIONS);
|
|
429
430
|
if (!action) return error("action required", 400);
|
|
@@ -464,7 +465,13 @@ export const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
464
465
|
// Everything else delegates to the shared core (one home, shared with the
|
|
465
466
|
// relay_workspace_* MCP tools); the core self-audits and returns any command to
|
|
466
467
|
// emit, mirroring requestWorkspaceMerge's "caller emits" contract.
|
|
467
|
-
|
|
468
|
+
if (action === "ready" || action === "request-review") {
|
|
469
|
+
await refreshWorkspaceGitState(workspace);
|
|
470
|
+
const refreshed = getWorkspace(workspace.id);
|
|
471
|
+
if (refreshed) workspace = refreshed;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const result = await applyWorkspaceAction(workspace, {
|
|
468
475
|
action,
|
|
469
476
|
agentId,
|
|
470
477
|
detail: cleanString(parsed.body.detail, "detail", { max: 4000 }),
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -82,6 +82,8 @@ export function issueOrchestratorRuntimeToken(input: {
|
|
|
82
82
|
cwdPrefixes: [input.baseDir],
|
|
83
83
|
logsRead: true,
|
|
84
84
|
terminalAttach: true,
|
|
85
|
+
terminalRead: true,
|
|
86
|
+
terminalWrite: true,
|
|
85
87
|
},
|
|
86
88
|
createdBy: input.createdBy ?? "runtime",
|
|
87
89
|
});
|
|
@@ -190,6 +192,8 @@ export function issueInteractiveRunnerRuntimeToken(input: {
|
|
|
190
192
|
agents: [agentId],
|
|
191
193
|
cwdPrefixes: [input.cwd],
|
|
192
194
|
terminalAttach: false,
|
|
195
|
+
terminalRead: false,
|
|
196
|
+
terminalWrite: false,
|
|
193
197
|
logsRead: false,
|
|
194
198
|
canDelegate: false,
|
|
195
199
|
},
|
package/src/security.ts
CHANGED
|
@@ -104,6 +104,9 @@ type AuthorizationResource = {
|
|
|
104
104
|
spawnRequestId?: string;
|
|
105
105
|
integrationName?: string;
|
|
106
106
|
terminalAttach?: boolean;
|
|
107
|
+
terminalRead?: boolean;
|
|
108
|
+
terminalWrite?: boolean;
|
|
109
|
+
terminalOwner?: boolean;
|
|
107
110
|
logsRead?: boolean;
|
|
108
111
|
};
|
|
109
112
|
|
|
@@ -147,7 +150,7 @@ export function isRequestAuthorizedFor(req: Request, check: AuthorizationCheck):
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
export function isComponentAuthorizedFor(auth: ComponentToken, check: AuthorizationCheck): boolean {
|
|
150
|
-
return hasComponentScope(auth, check.scope) && constraintsAllow(auth
|
|
153
|
+
return hasComponentScope(auth, check.scope) && constraintsAllow(auth, check.resource);
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
export function requiredScopeFor(method: string, pathname: string): string | null {
|
|
@@ -160,7 +163,8 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
|
|
|
160
163
|
if (pathname === "/api/orchestrators/bootstrap/exchange") return "token:write";
|
|
161
164
|
if (pathname === "/api/orchestrators/bootstrap") return "token:write";
|
|
162
165
|
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
|
|
163
|
-
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal
|
|
166
|
+
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\/[^/]+\/input$/)) return "terminal:write";
|
|
167
|
+
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:read";
|
|
164
168
|
const settingScope = requiredSettingsScopeFor(method, pathname);
|
|
165
169
|
if (settingScope !== undefined) return settingScope;
|
|
166
170
|
if (pathname.startsWith("/api/artifacts")) {
|
|
@@ -259,7 +263,8 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
|
|
|
259
263
|
if (pathname === "/api/events") return "message:read";
|
|
260
264
|
if (pathname === "/api/mcp") return "mcp:use";
|
|
261
265
|
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/logs\//)) return "logs:read";
|
|
262
|
-
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal
|
|
266
|
+
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\/[^/]+\/input$/)) return "terminal:write";
|
|
267
|
+
if (pathname.match(/^\/api\/orchestrators\/[^/]+\/terminal\//)) return "terminal:read";
|
|
263
268
|
const settingScope = requiredSettingsScopeFor(method, pathname);
|
|
264
269
|
if (settingScope !== undefined) return settingScope ?? "system:admin";
|
|
265
270
|
if (pathname.startsWith("/api/commands")) {
|
|
@@ -519,10 +524,12 @@ export function resolveTeamLineage(constraints: TokenConstraints | undefined): s
|
|
|
519
524
|
return constraints?.teamId;
|
|
520
525
|
}
|
|
521
526
|
|
|
522
|
-
function constraintsAllow(
|
|
523
|
-
|
|
527
|
+
function constraintsAllow(auth: ComponentToken, resource: AuthorizationResource | undefined): boolean {
|
|
528
|
+
const constraints = auth.constraints;
|
|
529
|
+
if (!resource) return true;
|
|
530
|
+
if (!constraints) return resource.terminalOwner ? terminalOwnerAllowed(auth, undefined, resource) : true;
|
|
524
531
|
const hasConstraints = Object.keys(constraints).length > 0;
|
|
525
|
-
if (!hasConstraints) return true;
|
|
532
|
+
if (!hasConstraints && !resource.terminalOwner) return true;
|
|
526
533
|
if (resource.agentId && !listAllows(constraints.agents, resource.agentId)) return false;
|
|
527
534
|
if (resource.policyName && !listAllows(constraints.policies, resource.policyName)) return false;
|
|
528
535
|
if (resource.channel && !listAllows(constraints.channels, resource.channel)) return false;
|
|
@@ -533,10 +540,27 @@ function constraintsAllow(constraints: TokenConstraints | undefined, resource: A
|
|
|
533
540
|
if (resource.cwd && !cwdAllows(constraints, resource.cwd)) return false;
|
|
534
541
|
if (resource.target && !targetAllows(constraints, resource.target)) return false;
|
|
535
542
|
if (resource.terminalAttach && constraints.terminalAttach !== true) return false;
|
|
543
|
+
if (resource.terminalRead && constraints.terminalRead !== true) return false;
|
|
544
|
+
if (resource.terminalWrite && constraints.terminalWrite !== true) return false;
|
|
536
545
|
if (resource.logsRead && constraints.logsRead !== true) return false;
|
|
546
|
+
if (resource.terminalOwner && !terminalOwnerAllowed(auth, constraints, resource)) return false;
|
|
537
547
|
return true;
|
|
538
548
|
}
|
|
539
549
|
|
|
550
|
+
function terminalOwnerAllowed(auth: ComponentToken, constraints: TokenConstraints | undefined, resource: AuthorizationResource): boolean {
|
|
551
|
+
if (hasScope(auth.scope, "system:admin")) return true;
|
|
552
|
+
if (auth.role === "orchestrator") {
|
|
553
|
+
return !resource.orchestratorId || listAllows(constraints?.orchestrators, resource.orchestratorId);
|
|
554
|
+
}
|
|
555
|
+
if (!constraints) return false;
|
|
556
|
+
if (resource.agentId && constraints.agents?.includes(resource.agentId)) return true;
|
|
557
|
+
if (resource.policyName && constraints.policies?.includes(resource.policyName)) return true;
|
|
558
|
+
if (resource.spawnRequestId && constraints.spawnRequestIds?.includes(resource.spawnRequestId)) return true;
|
|
559
|
+
if (resource.agentId && constraints.targets?.some((target) => target === resource.agentId || target === `agent:${resource.agentId}`)) return true;
|
|
560
|
+
if (resource.policyName && constraints.targets?.includes(`policy:${resource.policyName}`)) return true;
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
540
564
|
function targetAllows(constraints: TokenConstraints, target: string): boolean {
|
|
541
565
|
const hasTargetConstraints = Boolean(constraints.targets?.length || constraints.policies?.length || constraints.agents?.length);
|
|
542
566
|
if (constraints.targets?.includes(target)) return true;
|
|
@@ -564,7 +588,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
|
564
588
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
565
589
|
const record = value as Record<string, unknown>;
|
|
566
590
|
for (const [key, item] of Object.entries(record)) {
|
|
567
|
-
if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
|
|
591
|
+
if (["terminalAttach", "terminalRead", "terminalWrite", "logsRead", "canDelegate"].includes(key)) {
|
|
568
592
|
if (typeof item !== "boolean") return false;
|
|
569
593
|
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "profile" || key === "tenantId") {
|
|
570
594
|
if (typeof item !== "string") return false;
|
package/src/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
|
15
15
|
import { startRelayScheduler } from "./services/scheduler";
|
|
16
16
|
import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
|
|
17
17
|
import { onWorkspaceCreate } from "./db";
|
|
18
|
+
import { isTerminalSessionRequestAuthorized } from "./terminal-authz";
|
|
18
19
|
import { resolve, sep } from "path";
|
|
19
20
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
20
21
|
import {
|
|
@@ -35,7 +36,6 @@ import {
|
|
|
35
36
|
isAuthorized,
|
|
36
37
|
isScopedRequestAuthorized,
|
|
37
38
|
isOriginAllowed,
|
|
38
|
-
isRequestAuthorizedFor,
|
|
39
39
|
unauthorized,
|
|
40
40
|
} from "./security";
|
|
41
41
|
import { startMaintenanceScheduler } from "./maintenance";
|
|
@@ -93,20 +93,7 @@ export function startServer(): void {
|
|
|
93
93
|
hostname: HOST,
|
|
94
94
|
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
|
95
95
|
fetch: createFetchHandler({ publicDir: process.env.PUBLIC_DIR, logRequests: LOG_REQUESTS }),
|
|
96
|
-
websocket:
|
|
97
|
-
open(ws) {
|
|
98
|
-
if (ws.data?.kind === "terminal-proxy") return openTerminalProxy(ws as TerminalProxySocket);
|
|
99
|
-
return busHandleOpen(ws as any);
|
|
100
|
-
},
|
|
101
|
-
message(ws, data) {
|
|
102
|
-
if (ws.data?.kind === "terminal-proxy") return sendTerminalProxyFrame(ws as TerminalProxySocket, data);
|
|
103
|
-
return busHandleMessage(ws as any, data);
|
|
104
|
-
},
|
|
105
|
-
close(ws) {
|
|
106
|
-
if (ws.data?.kind === "terminal-proxy") return closeTerminalProxy(ws as TerminalProxySocket);
|
|
107
|
-
return busHandleClose(ws as any);
|
|
108
|
-
},
|
|
109
|
-
},
|
|
96
|
+
websocket: createWebSocketHandlers(),
|
|
110
97
|
});
|
|
111
98
|
|
|
112
99
|
console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
|
|
@@ -117,6 +104,7 @@ interface TerminalProxySocketData {
|
|
|
117
104
|
upstreamUrl: string;
|
|
118
105
|
upstreamToken?: string;
|
|
119
106
|
upstream?: WebSocket;
|
|
107
|
+
writeAuthorized: boolean;
|
|
120
108
|
pending: Array<string | Buffer>;
|
|
121
109
|
}
|
|
122
110
|
|
|
@@ -128,6 +116,23 @@ type RelaySocketData = {
|
|
|
128
116
|
|
|
129
117
|
type TerminalProxySocket = ServerWebSocket<TerminalProxySocketData>;
|
|
130
118
|
|
|
119
|
+
export function createWebSocketHandlers() {
|
|
120
|
+
return {
|
|
121
|
+
open(ws: ServerWebSocket<RelaySocketData>) {
|
|
122
|
+
if (ws.data?.kind === "terminal-proxy") return openTerminalProxy(ws as TerminalProxySocket);
|
|
123
|
+
return busHandleOpen(ws as any);
|
|
124
|
+
},
|
|
125
|
+
message(ws: ServerWebSocket<RelaySocketData>, data: string | Buffer) {
|
|
126
|
+
if (ws.data?.kind === "terminal-proxy") return sendTerminalProxyFrame(ws as TerminalProxySocket, data);
|
|
127
|
+
return busHandleMessage(ws as any, data);
|
|
128
|
+
},
|
|
129
|
+
close(ws: ServerWebSocket<RelaySocketData>) {
|
|
130
|
+
if (ws.data?.kind === "terminal-proxy") return closeTerminalProxy(ws as TerminalProxySocket);
|
|
131
|
+
return busHandleClose(ws as any);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
131
136
|
function emitAutomationDispatches(results: AutomationDispatchResult[]): void {
|
|
132
137
|
for (const result of results) {
|
|
133
138
|
if (result.command) emitAutomationCommand(result.command);
|
|
@@ -195,6 +200,10 @@ function openTerminalProxy(ws: TerminalProxySocket): void {
|
|
|
195
200
|
}
|
|
196
201
|
|
|
197
202
|
function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer): void {
|
|
203
|
+
if (!ws.data.writeAuthorized && isTerminalWriteFrame(data)) {
|
|
204
|
+
ws.send(JSON.stringify({ type: "error", error: "terminal write forbidden" }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
198
207
|
const upstream = ws.data.upstream;
|
|
199
208
|
if (!upstream || upstream.readyState === WebSocket.CONNECTING) {
|
|
200
209
|
ws.data.pending.push(data);
|
|
@@ -204,10 +213,22 @@ function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer):
|
|
|
204
213
|
upstream.send(data);
|
|
205
214
|
}
|
|
206
215
|
|
|
216
|
+
function isTerminalWriteFrame(data: string | Buffer): boolean {
|
|
217
|
+
let payload: unknown;
|
|
218
|
+
try {
|
|
219
|
+
payload = JSON.parse(typeof data === "string" ? data : data.toString("utf8"));
|
|
220
|
+
} catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
224
|
+
const frame = payload as Record<string, unknown>;
|
|
225
|
+
return frame.type === "input" || (frame.type === "interactive" && frame.interactive === true);
|
|
226
|
+
}
|
|
227
|
+
|
|
207
228
|
function closeTerminalProxy(ws: TerminalProxySocket): void {
|
|
208
229
|
const upstream = ws.data.upstream;
|
|
209
230
|
ws.data.pending = [];
|
|
210
|
-
if (upstream && upstream.readyState
|
|
231
|
+
if (upstream && upstream.readyState !== WebSocket.CLOSED) upstream.close();
|
|
211
232
|
}
|
|
212
233
|
|
|
213
234
|
export function createFetchHandler(
|
|
@@ -235,9 +256,10 @@ export function createFetchHandler(
|
|
|
235
256
|
if (!isAuthorized(req)) return unauthorized(req);
|
|
236
257
|
const orchestratorId = decodeURIComponent(terminalStreamMatch[1]!);
|
|
237
258
|
const session = decodeURIComponent(terminalStreamMatch[2]!);
|
|
238
|
-
if (!
|
|
259
|
+
if (!isTerminalSessionRequestAuthorized(req, orchestratorId, session, "read")) {
|
|
239
260
|
return forbidden(req);
|
|
240
261
|
}
|
|
262
|
+
const writeAuthorized = isTerminalSessionRequestAuthorized(req, orchestratorId, session, "write");
|
|
241
263
|
const orch = getOrchestrator(orchestratorId);
|
|
242
264
|
if (!orch) return Response.json({ error: "orchestrator not found" }, { status: 404 });
|
|
243
265
|
if (!orch.apiUrl) return Response.json({ error: "orchestrator does not expose an API" }, { status: 422 });
|
|
@@ -248,7 +270,7 @@ export function createFetchHandler(
|
|
|
248
270
|
// access/proxy logs (#149). The orchestrator accepts both, but headers are safer.
|
|
249
271
|
const token = relayToken();
|
|
250
272
|
const upgraded = server.upgrade(req, {
|
|
251
|
-
data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, pending: [] },
|
|
273
|
+
data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, writeAuthorized, pending: [] },
|
|
252
274
|
});
|
|
253
275
|
if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
|
|
254
276
|
return undefined;
|