agent-relay-server 0.118.5 → 0.119.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 +6 -5
- package/public/assets/{MessageBubble-BdnzeQjY.js → MessageBubble-DbaaemH3.js} +2 -2
- package/public/assets/{MessageBubble-BdnzeQjY.js.map → MessageBubble-DbaaemH3.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-sG8nqUHw.js → PlanGraphPanel-C_hnK9yH.js} +2 -2
- package/public/assets/{PlanGraphPanel-sG8nqUHw.js.map → PlanGraphPanel-C_hnK9yH.js.map} +1 -1
- package/public/assets/{activity-CZpvbkAW.js → activity-Cbw43afW.js} +2 -2
- package/public/assets/{activity-CZpvbkAW.js.map → activity-Cbw43afW.js.map} +1 -1
- package/public/assets/{agent-profiles-BfZgLPvN.js → agent-profiles-BZZpTAZn.js} +2 -2
- package/public/assets/{agent-profiles-BfZgLPvN.js.map → agent-profiles-BZZpTAZn.js.map} +1 -1
- package/public/assets/{agent-type-icon-CG2v7tF0.js → agent-type-icon-2YWBWhYC.js} +2 -2
- package/public/assets/{agent-type-icon-CG2v7tF0.js.map → agent-type-icon-2YWBWhYC.js.map} +1 -1
- package/public/assets/{agents-DSOCIAV6.js → agents-nvHlibqo.js} +2 -2
- package/public/assets/{agents-DSOCIAV6.js.map → agents-nvHlibqo.js.map} +1 -1
- package/public/assets/{analytics-C3vhiVvA.js → analytics-B9bbDReP.js} +2 -2
- package/public/assets/{analytics-C3vhiVvA.js.map → analytics-B9bbDReP.js.map} +1 -1
- package/public/assets/{automation-RkZtUqxK.js → automation-CjtLgS3h.js} +2 -2
- package/public/assets/{automation-RkZtUqxK.js.map → automation-CjtLgS3h.js.map} +1 -1
- package/public/assets/{branch-state-badge-BG7WdFNG.js → branch-state-badge-DRFIs9q0.js} +2 -2
- package/public/assets/{branch-state-badge-BG7WdFNG.js.map → branch-state-badge-DRFIs9q0.js.map} +1 -1
- package/public/assets/{channels-CF3EsyIK.js → channels-CL00bLDh.js} +2 -2
- package/public/assets/{channels-CF3EsyIK.js.map → channels-CL00bLDh.js.map} +1 -1
- package/public/assets/{chat-E5MI0WxW.js → chat-DKf_9fgB.js} +2 -2
- package/public/assets/{chat-E5MI0WxW.js.map → chat-DKf_9fgB.js.map} +1 -1
- package/public/assets/{connectors-CitVhhON.js → connectors-QYtScfNz.js} +2 -2
- package/public/assets/{connectors-CitVhhON.js.map → connectors-QYtScfNz.js.map} +1 -1
- package/public/assets/{display-_5kFrHJh.js → display-CP3jEw1H.js} +2 -2
- package/public/assets/{display-_5kFrHJh.js.map → display-CP3jEw1H.js.map} +1 -1
- package/public/assets/{formatted-body-BnlRQjBi.js → formatted-body-BnSiFCc3.js} +3 -3
- package/public/assets/{formatted-body-BnlRQjBi.js.map → formatted-body-BnSiFCc3.js.map} +1 -1
- package/public/assets/{formatted-body-impl-9iNv975q.js → formatted-body-impl-Cnchy-Iv.js} +2 -2
- package/public/assets/{formatted-body-impl-9iNv975q.js.map → formatted-body-impl-Cnchy-Iv.js.map} +1 -1
- package/public/assets/{index-CUugubHc.js → index-kelZvgcV.js} +6 -6
- package/public/assets/{index-CUugubHc.js.map → index-kelZvgcV.js.map} +1 -1
- package/public/assets/{insights-D0bcKaNd.js → insights-Ccz5zFIQ.js} +2 -2
- package/public/assets/{insights-D0bcKaNd.js.map → insights-Ccz5zFIQ.js.map} +1 -1
- package/public/assets/{integrations-CGVHvoEv.js → integrations-BICEazmP.js} +2 -2
- package/public/assets/{integrations-CGVHvoEv.js.map → integrations-BICEazmP.js.map} +1 -1
- package/public/assets/{maintenance-C0vzzP7r.js → maintenance-DpYnmrVo.js} +2 -2
- package/public/assets/{maintenance-C0vzzP7r.js.map → maintenance-DpYnmrVo.js.map} +1 -1
- package/public/assets/{managed-agents-DUkT3MHx.js → managed-agents-qQtr531p.js} +2 -2
- package/public/assets/{managed-agents-DUkT3MHx.js.map → managed-agents-qQtr531p.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CDHHjZr3.js → markdown-preview-impl-DhkWrgsW.js} +2 -2
- package/public/assets/{markdown-preview-impl-CDHHjZr3.js.map → markdown-preview-impl-DhkWrgsW.js.map} +1 -1
- package/public/assets/{memory-hrJcJKdD.js → memory-AsWF2gfa.js} +2 -2
- package/public/assets/{memory-hrJcJKdD.js.map → memory-AsWF2gfa.js.map} +1 -1
- package/public/assets/{messages-C3YD6xJ3.js → messages-D0vT-ovM.js} +2 -2
- package/public/assets/{messages-C3YD6xJ3.js.map → messages-D0vT-ovM.js.map} +1 -1
- package/public/assets/{orchestrators-CshVnsUu.js → orchestrators-Oo8U_GW2.js} +2 -2
- package/public/assets/{orchestrators-CshVnsUu.js.map → orchestrators-Oo8U_GW2.js.map} +1 -1
- package/public/assets/{overview-DEtzai33.js → overview-UTAK7pAB.js} +2 -2
- package/public/assets/{overview-DEtzai33.js.map → overview-UTAK7pAB.js.map} +1 -1
- package/public/assets/{pairs-DFpYOOlU.js → pairs-TZYezwEi.js} +2 -2
- package/public/assets/{pairs-DFpYOOlU.js.map → pairs-TZYezwEi.js.map} +1 -1
- package/public/assets/{projects-BAi5dytW.js → projects-ClgQ10Al.js} +2 -2
- package/public/assets/{projects-BAi5dytW.js.map → projects-ClgQ10Al.js.map} +1 -1
- package/public/assets/{prompt-settings-jpRG8tAv.js → prompt-settings-Tia685AV.js} +2 -2
- package/public/assets/{prompt-settings-jpRG8tAv.js.map → prompt-settings-Tia685AV.js.map} +1 -1
- package/public/assets/{registry-DFiSS4hB.js → registry-CAXZWvzr.js} +2 -2
- package/public/assets/{registry-DFiSS4hB.js.map → registry-CAXZWvzr.js.map} +1 -1
- package/public/assets/{security-D5tkecYh.js → security-BDAMCUzN.js} +2 -2
- package/public/assets/{security-D5tkecYh.js.map → security-BDAMCUzN.js.map} +1 -1
- package/public/assets/{select-DctZnR2Q.js → select-ytVYIxZP.js} +2 -2
- package/public/assets/{select-DctZnR2Q.js.map → select-ytVYIxZP.js.map} +1 -1
- package/public/assets/{settings-NlRG3Wvf.js → settings-CnalH4Zd.js} +2 -2
- package/public/assets/{settings-NlRG3Wvf.js.map → settings-CnalH4Zd.js.map} +1 -1
- package/public/assets/store-BDCUnpt5.js +9 -0
- package/public/assets/store-BDCUnpt5.js.map +1 -0
- package/public/assets/{tasks-Q9e41WzI.js → tasks-CHGO51En.js} +2 -2
- package/public/assets/{tasks-Q9e41WzI.js.map → tasks-CHGO51En.js.map} +1 -1
- package/public/assets/{teams-pyH9kHhS.js → teams-DDL-I-pP.js} +2 -2
- package/public/assets/{teams-pyH9kHhS.js.map → teams-DDL-I-pP.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-RPC_OOk0.js → terminal-viewer-impl-CnXpHy1R.js} +2 -2
- package/public/assets/{terminal-viewer-impl-RPC_OOk0.js.map → terminal-viewer-impl-CnXpHy1R.js.map} +1 -1
- package/public/assets/types-8ThujPP2.js +2 -0
- package/public/assets/types-8ThujPP2.js.map +1 -0
- package/public/assets/{user-avatar-Bd4txMth.js → user-avatar-Cw7jcQY-.js} +2 -2
- package/public/assets/{user-avatar-Bd4txMth.js.map → user-avatar-Cw7jcQY-.js.map} +1 -1
- package/public/assets/{work-queue-CWLzjgBV.js → work-queue-BwX92bVS.js} +2 -2
- package/public/assets/{work-queue-CWLzjgBV.js.map → work-queue-BwX92bVS.js.map} +1 -1
- package/public/assets/{workspaces-BV9-9JQr.js → workspaces-DSsvsOFa.js} +2 -2
- package/public/assets/{workspaces-BV9-9JQr.js.map → workspaces-DSsvsOFa.js.map} +1 -1
- package/public/index.html +6 -6
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/claude-statusline-probe.cjs +134 -0
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +58 -3
- package/src/cli/context-probe.ts +9 -7
- package/src/commands-db.ts +57 -2
- package/src/index.ts +10 -434
- package/src/routing-policy-store.ts +5 -5
- package/src/server.ts +433 -0
- package/src/services/task-context-hydration.ts +7 -2
- package/src/spawn-targets.ts +31 -4
- package/public/assets/store-D2BjyanL.js +0 -9
- package/public/assets/store-D2BjyanL.js.map +0 -1
- package/public/assets/types-DSwhOFcJ.js +0 -2
- package/public/assets/types-DSwhOFcJ.js.map +0 -1
package/src/server.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { initDb, getOrchestrator } from "./db";
|
|
3
|
+
import { matchRoute } from "./routes";
|
|
4
|
+
import { emitNewMessage, emitTaskChanged } from "./sse";
|
|
5
|
+
import { busHandleClose, busHandleMessage, busHandleOpen, handleBusUpgrade } from "./bus";
|
|
6
|
+
import { emitRelayEvent } from "./events";
|
|
7
|
+
import { reconcileAutomationRuns, runDueAutomations, type AutomationDispatchResult, type AutomationReconcileResult } from "./automations";
|
|
8
|
+
import { getLifecycleManager } from "./lifecycle-manager";
|
|
9
|
+
import { getCompactionWatch } from "./compaction-watch";
|
|
10
|
+
import { getConfig, setConfig } from "./config-store";
|
|
11
|
+
import { startConnectorStatusPoller } from "./connectors";
|
|
12
|
+
import { installNotificationSubscriptionRule } from "./notification-subscription-rules";
|
|
13
|
+
import { installQuotaAdvisoryHandler } from "./quota-advisory";
|
|
14
|
+
import { startPlanLiveBindings } from "./services/plan-live-bindings";
|
|
15
|
+
import { startRelayScheduler } from "./services/scheduler";
|
|
16
|
+
import { recordWorkspaceIssueActivity } from "./services/spawn-issue-activity";
|
|
17
|
+
import { onWorkspaceCreate } from "./db";
|
|
18
|
+
import { resolve, sep } from "path";
|
|
19
|
+
import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
|
|
20
|
+
import {
|
|
21
|
+
AUTOMATION_INTERVAL_MS,
|
|
22
|
+
IDLE_TIMEOUT_SECONDS,
|
|
23
|
+
LOG_REQUESTS,
|
|
24
|
+
VERSION,
|
|
25
|
+
relayToken,
|
|
26
|
+
} from "./config";
|
|
27
|
+
import { CONTRACT_VERSIONS } from "./contracts";
|
|
28
|
+
import {
|
|
29
|
+
applyCors,
|
|
30
|
+
assertSafeNetworkConfig,
|
|
31
|
+
corsPreflight,
|
|
32
|
+
forbidden,
|
|
33
|
+
getComponentAuth,
|
|
34
|
+
getIntegrationAuth,
|
|
35
|
+
isAuthorized,
|
|
36
|
+
isScopedRequestAuthorized,
|
|
37
|
+
isOriginAllowed,
|
|
38
|
+
isRequestAuthorizedFor,
|
|
39
|
+
unauthorized,
|
|
40
|
+
} from "./security";
|
|
41
|
+
import { startMaintenanceScheduler } from "./maintenance";
|
|
42
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
43
|
+
|
|
44
|
+
// Restart-on-update gating (issue #341).
|
|
45
|
+
//
|
|
46
|
+
// A `restartOnUpdate` policy should cycle its managed agent only when the agent's
|
|
47
|
+
// running runner code is now protocol-INCOMPATIBLE with the relay — i.e. on a
|
|
48
|
+
// runner-protocol bump — NOT on every package version change. Gating on the package
|
|
49
|
+
// version churned the always-on Telegram agent ~12×/day (one restart per patch
|
|
50
|
+
// redeploy), dropping in-flight work (surfaced #340). The runner protocol
|
|
51
|
+
// (CONTRACT_VERSIONS.runnerProtocol) only moves on a breaking runner change, so this
|
|
52
|
+
// fires ~never during normal releases.
|
|
53
|
+
//
|
|
54
|
+
// `relay-version` is still tracked for observability/telemetry, but it no longer
|
|
55
|
+
// triggers restarts.
|
|
56
|
+
export function reconcileRelayVersion(): void {
|
|
57
|
+
const storedVersion = getConfig<string>("system", "relay-version")?.value;
|
|
58
|
+
if (storedVersion !== VERSION) setConfig("system", "relay-version", VERSION, "server-startup");
|
|
59
|
+
|
|
60
|
+
const storedProtocol = getConfig<string>("system", "runner-protocol-version")?.value;
|
|
61
|
+
const currentProtocol = String(CONTRACT_VERSIONS.runnerProtocol);
|
|
62
|
+
if (storedProtocol === currentProtocol) return;
|
|
63
|
+
// Skip the very first run (no stored protocol yet) — that's initial bootstrap, not an upgrade.
|
|
64
|
+
if (storedProtocol) getLifecycleManager().onRelayUpdated(VERSION);
|
|
65
|
+
setConfig("system", "runner-protocol-version", currentProtocol, "server-startup");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function startServer(): void {
|
|
69
|
+
const PORT = Number(process.env.PORT) || 4850;
|
|
70
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
71
|
+
const DB_PATH = process.env.DB_PATH || "agent-relay.db";
|
|
72
|
+
|
|
73
|
+
assertSafeNetworkConfig(HOST);
|
|
74
|
+
initDb(DB_PATH);
|
|
75
|
+
onWorkspaceCreate(recordWorkspaceIssueActivity);
|
|
76
|
+
installQuotaAdvisoryHandler();
|
|
77
|
+
installNotificationSubscriptionRule();
|
|
78
|
+
startPlanLiveBindings();
|
|
79
|
+
getLifecycleManager().start();
|
|
80
|
+
getCompactionWatch().start();
|
|
81
|
+
reconcileRelayVersion();
|
|
82
|
+
startConnectorStatusPoller();
|
|
83
|
+
startMaintenanceScheduler();
|
|
84
|
+
startRelayScheduler();
|
|
85
|
+
|
|
86
|
+
setInterval(() => {
|
|
87
|
+
emitAutomationReconciliations(reconcileAutomationRuns());
|
|
88
|
+
emitAutomationDispatches(runDueAutomations());
|
|
89
|
+
}, AUTOMATION_INTERVAL_MS);
|
|
90
|
+
|
|
91
|
+
Bun.serve<RelaySocketData>({
|
|
92
|
+
port: PORT,
|
|
93
|
+
hostname: HOST,
|
|
94
|
+
idleTimeout: IDLE_TIMEOUT_SECONDS,
|
|
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
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
console.log(`agent-relay ${VERSION} running on http://${HOST}:${PORT}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface TerminalProxySocketData {
|
|
116
|
+
kind: "terminal-proxy";
|
|
117
|
+
upstreamUrl: string;
|
|
118
|
+
upstreamToken?: string;
|
|
119
|
+
upstream?: WebSocket;
|
|
120
|
+
pending: Array<string | Buffer>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type RelaySocketData = {
|
|
124
|
+
kind: "bus";
|
|
125
|
+
id: string;
|
|
126
|
+
componentScopes?: string[];
|
|
127
|
+
} | TerminalProxySocketData;
|
|
128
|
+
|
|
129
|
+
type TerminalProxySocket = ServerWebSocket<TerminalProxySocketData>;
|
|
130
|
+
|
|
131
|
+
function emitAutomationDispatches(results: AutomationDispatchResult[]): void {
|
|
132
|
+
for (const result of results) {
|
|
133
|
+
if (result.command) emitAutomationCommand(result.command);
|
|
134
|
+
if (result.message) emitNewMessage(result.message);
|
|
135
|
+
if (result.task) emitTaskChanged(result.task, "task.created");
|
|
136
|
+
emitRelayEvent({
|
|
137
|
+
type: "automation.run",
|
|
138
|
+
source: "server",
|
|
139
|
+
subject: result.run.id,
|
|
140
|
+
data: { automation: result.automation, run: result.run, task: result.task },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function emitAutomationReconciliations(results: AutomationReconcileResult[]): void {
|
|
146
|
+
for (const result of results) {
|
|
147
|
+
if (result.command) emitAutomationCommand(result.command);
|
|
148
|
+
if (result.message) emitNewMessage(result.message);
|
|
149
|
+
if (result.task) emitTaskChanged(result.task, "task.updated");
|
|
150
|
+
emitRelayEvent({
|
|
151
|
+
type: "automation.run",
|
|
152
|
+
source: "server",
|
|
153
|
+
subject: result.run.id,
|
|
154
|
+
data: { run: result.run, task: result.task },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function emitAutomationCommand(command: { id: string; source: string; status: string }): void {
|
|
160
|
+
emitRelayEvent({
|
|
161
|
+
type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
|
|
162
|
+
source: command.source,
|
|
163
|
+
subject: command.id,
|
|
164
|
+
data: { command },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function openTerminalProxy(ws: TerminalProxySocket): void {
|
|
169
|
+
const token = ws.data.upstreamToken;
|
|
170
|
+
// Bun's client WebSocket accepts a `headers` option (non-standard; absent from DOM types).
|
|
171
|
+
const upstream = token
|
|
172
|
+
? new WebSocket(ws.data.upstreamUrl, { headers: { [RELAY_TOKEN_HEADER]: token } } as unknown as string[])
|
|
173
|
+
: new WebSocket(ws.data.upstreamUrl);
|
|
174
|
+
ws.data.upstream = upstream;
|
|
175
|
+
|
|
176
|
+
upstream.onopen = () => {
|
|
177
|
+
const pending = ws.data.pending.splice(0);
|
|
178
|
+
for (const frame of pending) upstream.send(frame);
|
|
179
|
+
};
|
|
180
|
+
upstream.onmessage = (event) => {
|
|
181
|
+
if (typeof event.data === "string" || event.data instanceof Buffer) {
|
|
182
|
+
ws.send(event.data);
|
|
183
|
+
} else if (event.data instanceof ArrayBuffer) {
|
|
184
|
+
ws.send(event.data);
|
|
185
|
+
} else if (event.data instanceof Blob) {
|
|
186
|
+
void event.data.arrayBuffer().then((buffer) => ws.send(buffer)).catch(() => {});
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
upstream.onerror = () => {
|
|
190
|
+
ws.send(JSON.stringify({ type: "error", error: "terminal stream upstream error" }));
|
|
191
|
+
};
|
|
192
|
+
upstream.onclose = () => {
|
|
193
|
+
ws.close();
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function sendTerminalProxyFrame(ws: TerminalProxySocket, data: string | Buffer): void {
|
|
198
|
+
const upstream = ws.data.upstream;
|
|
199
|
+
if (!upstream || upstream.readyState === WebSocket.CONNECTING) {
|
|
200
|
+
ws.data.pending.push(data);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (upstream.readyState !== WebSocket.OPEN) return;
|
|
204
|
+
upstream.send(data);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function closeTerminalProxy(ws: TerminalProxySocket): void {
|
|
208
|
+
const upstream = ws.data.upstream;
|
|
209
|
+
ws.data.pending = [];
|
|
210
|
+
if (upstream && upstream.readyState === WebSocket.OPEN) upstream.close();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createFetchHandler(
|
|
214
|
+
opts: { publicDir?: string; logRequests?: boolean } = {},
|
|
215
|
+
): {
|
|
216
|
+
(req: Request): Promise<Response>;
|
|
217
|
+
(req: Request, server: Bun.Server<RelaySocketData>): Promise<Response | undefined>;
|
|
218
|
+
} {
|
|
219
|
+
const publicDir = resolve(opts.publicDir ?? resolve(import.meta.dir, "../public"));
|
|
220
|
+
const publicDirPrefix = publicDir + sep;
|
|
221
|
+
const logRequests = opts.logRequests ?? false;
|
|
222
|
+
|
|
223
|
+
return async function fetch(req: Request, server?: Bun.Server<RelaySocketData>): Promise<Response | undefined> {
|
|
224
|
+
const url = new URL(req.url);
|
|
225
|
+
|
|
226
|
+
if (url.pathname === "/bus") {
|
|
227
|
+
if (!server) return new Response("WebSocket upgrade unavailable", { status: 400 });
|
|
228
|
+
return handleBusUpgrade(req, server);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const terminalStreamMatch = url.pathname.match(/^\/api\/orchestrators\/([^/]+)\/terminal\/([^/]+)\/stream$/);
|
|
232
|
+
if (req.method === "GET" && terminalStreamMatch) {
|
|
233
|
+
if (!server) return new Response("WebSocket upgrade unavailable", { status: 400 });
|
|
234
|
+
if (!isOriginAllowed(req)) return Response.json({ error: "origin not allowed" }, { status: 403 });
|
|
235
|
+
if (!isAuthorized(req)) return unauthorized(req);
|
|
236
|
+
const orchestratorId = decodeURIComponent(terminalStreamMatch[1]!);
|
|
237
|
+
const session = decodeURIComponent(terminalStreamMatch[2]!);
|
|
238
|
+
if (!isRequestAuthorizedFor(req, { scope: "terminal:attach", resource: { orchestratorId, terminalAttach: true } })) {
|
|
239
|
+
return forbidden(req);
|
|
240
|
+
}
|
|
241
|
+
const orch = getOrchestrator(orchestratorId);
|
|
242
|
+
if (!orch) return Response.json({ error: "orchestrator not found" }, { status: 404 });
|
|
243
|
+
if (!orch.apiUrl) return Response.json({ error: "orchestrator does not expose an API" }, { status: 422 });
|
|
244
|
+
if (orch.status !== "online") return Response.json({ error: "orchestrator is offline" }, { status: 422 });
|
|
245
|
+
const upstream = new URL(`/api/terminal/${encodeURIComponent(session)}/stream`, orch.apiUrl);
|
|
246
|
+
upstream.protocol = upstream.protocol === "https:" ? "wss:" : "ws:";
|
|
247
|
+
// Pass the relay token as a header (not a query param) so it never lands in
|
|
248
|
+
// access/proxy logs (#149). The orchestrator accepts both, but headers are safer.
|
|
249
|
+
const token = relayToken();
|
|
250
|
+
const upgraded = server.upgrade(req, {
|
|
251
|
+
data: { kind: "terminal-proxy", upstreamUrl: upstream.toString(), upstreamToken: token, pending: [] },
|
|
252
|
+
});
|
|
253
|
+
if (!upgraded) return new Response("WebSocket upgrade failed", { status: 400 });
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (req.method === "OPTIONS") {
|
|
258
|
+
return corsPreflight(req);
|
|
259
|
+
}
|
|
260
|
+
if (!isOriginAllowed(req)) {
|
|
261
|
+
return Response.json({ error: "origin not allowed" }, { status: 403 });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// API routes
|
|
265
|
+
const matched = matchRoute(req.method, url.pathname);
|
|
266
|
+
if (matched) {
|
|
267
|
+
const publicPaths = ["/api/spec", "/api/docs", "/api/health"];
|
|
268
|
+
// Pre-auth allowlist (#389): the auth endpoints must be reachable unauthenticated — they ARE
|
|
269
|
+
// the way a session is obtained. Each handler self-authenticates (login verifies credentials,
|
|
270
|
+
// refresh validates the refresh token, logout revokes the presented token). Everything else
|
|
271
|
+
// still flows through the component-token gate below.
|
|
272
|
+
const publicAuthPaths = ["/api/auth/providers", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout"];
|
|
273
|
+
if (!publicPaths.includes(url.pathname) && !publicAuthPaths.includes(url.pathname)) {
|
|
274
|
+
const integrationAuth = getIntegrationAuth(req);
|
|
275
|
+
const componentAuth = getComponentAuth(req);
|
|
276
|
+
const handlerOwnsScopedAuth = url.pathname === "/api/tokens/me" ||
|
|
277
|
+
url.pathname === "/api/settings" ||
|
|
278
|
+
url.pathname.startsWith("/api/settings/");
|
|
279
|
+
if (!isAuthorized(req)) {
|
|
280
|
+
if (!integrationAuth) {
|
|
281
|
+
return unauthorized(req);
|
|
282
|
+
}
|
|
283
|
+
if (!handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
|
|
284
|
+
}
|
|
285
|
+
if (componentAuth && !handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
|
|
286
|
+
}
|
|
287
|
+
const response = await matched.handler(req, matched.params);
|
|
288
|
+
applyCors(req, response);
|
|
289
|
+
if (logRequests && url.pathname.startsWith("/api/")) {
|
|
290
|
+
console.log(`${req.method} ${url.pathname} → ${response.status}`);
|
|
291
|
+
}
|
|
292
|
+
return response;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Dashboard — serve static files, rejecting path traversal and directory requests
|
|
296
|
+
let requested = url.pathname === "/" ? "/index.html" : url.pathname;
|
|
297
|
+
if (requested.endsWith("/")) requested += "index.html";
|
|
298
|
+
const resolved = resolve(publicDir, `.${requested}`);
|
|
299
|
+
if (!resolved.startsWith(publicDirPrefix)) {
|
|
300
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
301
|
+
}
|
|
302
|
+
const file = Bun.file(resolved);
|
|
303
|
+
if (await file.exists()) {
|
|
304
|
+
return await serveStaticFile(req, resolved, requested, file);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
308
|
+
} as {
|
|
309
|
+
(req: Request): Promise<Response>;
|
|
310
|
+
(req: Request, server: Bun.Server<RelaySocketData>): Promise<Response | undefined>;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// In-memory compressed-variant cache, keyed by absolute path. Invalidated when
|
|
315
|
+
// the file's mtime/size changes, so a rebuilt bundle is recompressed on next
|
|
316
|
+
// request. The dashboard ships as a minified, code-split bundle (#200/#201);
|
|
317
|
+
// brotli/gzip on top cuts the wire size further, which is the dominant cost over
|
|
318
|
+
// high-latency links. Hashed /assets/* chunks are also served immutable so
|
|
319
|
+
// repeat visits skip them entirely (see staticHeaders).
|
|
320
|
+
type CompressedVariant = { body: Uint8Array; encoding: "br" | "gzip" };
|
|
321
|
+
const compressedCache = new Map<
|
|
322
|
+
string,
|
|
323
|
+
{ mtimeMs: number; size: number; br?: Uint8Array; gzip?: Uint8Array }
|
|
324
|
+
>();
|
|
325
|
+
|
|
326
|
+
const COMPRESSIBLE = /\.(html|js|css|svg|json|webmanifest|map)$/;
|
|
327
|
+
|
|
328
|
+
function isCompressible(pathname: string): boolean {
|
|
329
|
+
return pathname === "/" || pathname.endsWith("/") || COMPRESSIBLE.test(pathname);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function negotiateEncoding(req: Request): "br" | "gzip" | null {
|
|
333
|
+
const accept = req.headers.get("accept-encoding") ?? "";
|
|
334
|
+
if (/\bbr\b/.test(accept)) return "br";
|
|
335
|
+
if (/\bgzip\b/.test(accept)) return "gzip";
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function getCompressedVariant(
|
|
340
|
+
resolved: string,
|
|
341
|
+
mtimeMs: number,
|
|
342
|
+
size: number,
|
|
343
|
+
encoding: "br" | "gzip",
|
|
344
|
+
raw: () => Promise<Uint8Array>,
|
|
345
|
+
): Promise<CompressedVariant> {
|
|
346
|
+
let entry = compressedCache.get(resolved);
|
|
347
|
+
if (!entry || entry.mtimeMs !== mtimeMs || entry.size !== size) {
|
|
348
|
+
entry = { mtimeMs, size };
|
|
349
|
+
compressedCache.set(resolved, entry);
|
|
350
|
+
}
|
|
351
|
+
const cached = entry[encoding];
|
|
352
|
+
if (cached) return { body: cached, encoding };
|
|
353
|
+
const data = await raw();
|
|
354
|
+
const body =
|
|
355
|
+
encoding === "br"
|
|
356
|
+
? brotliCompressSync(data, {
|
|
357
|
+
params: {
|
|
358
|
+
// Quality 5 ~ near-gzip CPU, much better ratio; this runs once per
|
|
359
|
+
// file version then serves from cache, so cost is amortized away.
|
|
360
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 5,
|
|
361
|
+
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.byteLength,
|
|
362
|
+
},
|
|
363
|
+
})
|
|
364
|
+
: gzipSync(data, { level: 6 });
|
|
365
|
+
entry[encoding] = body;
|
|
366
|
+
return { body, encoding };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function serveStaticFile(
|
|
370
|
+
req: Request,
|
|
371
|
+
resolved: string,
|
|
372
|
+
requested: string,
|
|
373
|
+
file: ReturnType<typeof Bun.file>,
|
|
374
|
+
): Promise<Response> {
|
|
375
|
+
const stat = await file.stat();
|
|
376
|
+
const headers = staticHeaders(requested);
|
|
377
|
+
// Strong-ish validator from mtime + size; encoding-suffixed so a client never
|
|
378
|
+
// gets a 304 for a variant it didn't store.
|
|
379
|
+
const baseTag = `${stat.mtime.getTime().toString(36)}-${stat.size.toString(36)}`;
|
|
380
|
+
|
|
381
|
+
const encoding = isCompressible(requested) ? negotiateEncoding(req) : null;
|
|
382
|
+
const etag = `"${baseTag}${encoding ? `-${encoding}` : ""}"`;
|
|
383
|
+
headers.set("ETag", etag);
|
|
384
|
+
if (req.headers.get("if-none-match") === etag) {
|
|
385
|
+
return new Response(null, { status: 304, headers });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (encoding) {
|
|
389
|
+
const { body } = await getCompressedVariant(
|
|
390
|
+
resolved,
|
|
391
|
+
stat.mtime.getTime(),
|
|
392
|
+
stat.size,
|
|
393
|
+
encoding,
|
|
394
|
+
() => file.bytes(),
|
|
395
|
+
);
|
|
396
|
+
headers.set("Content-Encoding", encoding);
|
|
397
|
+
headers.set("Vary", "Accept-Encoding");
|
|
398
|
+
headers.set("Content-Length", String(body.byteLength));
|
|
399
|
+
// Uint8Array is a valid BodyInit at runtime in Bun; the DOM lib types omit it.
|
|
400
|
+
return new Response(body as unknown as BodyInit, { headers });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return new Response(file, { headers });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function staticHeaders(pathname: string): Headers {
|
|
407
|
+
const headers = new Headers();
|
|
408
|
+
const contentType = staticContentType(pathname);
|
|
409
|
+
if (contentType) headers.set("Content-Type", contentType);
|
|
410
|
+
// Hashed build assets are content-addressed (a content change = a new
|
|
411
|
+
// filename), so they're safe to cache forever — the browser never needs to
|
|
412
|
+
// revalidate them. The shell + PWA control files must always revalidate
|
|
413
|
+
// instead (ETag makes that a cheap 304 when unchanged).
|
|
414
|
+
if (pathname.startsWith("/assets/")) {
|
|
415
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
416
|
+
} else if (pathname === "/sw.js" || pathname === "/manifest.webmanifest") {
|
|
417
|
+
headers.set("Cache-Control", "no-cache");
|
|
418
|
+
} else if (pathname === "/" || pathname.endsWith("/") || pathname.endsWith(".html")) {
|
|
419
|
+
headers.set("Cache-Control", "no-cache");
|
|
420
|
+
}
|
|
421
|
+
return headers;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function staticContentType(pathname: string): string | undefined {
|
|
425
|
+
if (pathname.endsWith(".webmanifest")) return "application/manifest+json; charset=utf-8";
|
|
426
|
+
if (pathname.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
427
|
+
if (pathname.endsWith(".css")) return "text/css; charset=utf-8";
|
|
428
|
+
if (pathname.endsWith(".map")) return "application/json; charset=utf-8";
|
|
429
|
+
if (pathname.endsWith(".html")) return "text/html; charset=utf-8";
|
|
430
|
+
if (pathname.endsWith(".svg")) return "image/svg+xml";
|
|
431
|
+
if (pathname.endsWith(".png")) return "image/png";
|
|
432
|
+
return undefined;
|
|
433
|
+
}
|
|
@@ -338,7 +338,9 @@ function joinBlocks(...parts: Array<string | undefined>): string | undefined {
|
|
|
338
338
|
* single instruction source. Caller system append is preserved unless it already embeds the
|
|
339
339
|
* hydrated brief, which avoids delivering the same brief through both provider channels.
|
|
340
340
|
* The steer used as a first-message kick is sentinel-stripped so it cannot forge the data fence
|
|
341
|
-
* in the (instruction-bearing) systemPromptAppend brief.
|
|
341
|
+
* in the (instruction-bearing) systemPromptAppend brief. #927 — for the append-system-prompt
|
|
342
|
+
* channel a spawn with no steer falls back to submitting the hydrated brief as turn 1, so the
|
|
343
|
+
* worker is never left with an empty prompt (which left task-bound Claude spawns idling).
|
|
342
344
|
*/
|
|
343
345
|
export function projectTaskHydration(
|
|
344
346
|
provider: SpawnProvider | string,
|
|
@@ -352,7 +354,10 @@ export function projectTaskHydration(
|
|
|
352
354
|
if (taskHydrationChannel(provider) === "append-system-prompt") {
|
|
353
355
|
return {
|
|
354
356
|
systemPromptAppend: joinBlocks(context, callerAppend),
|
|
355
|
-
|
|
357
|
+
// #927 — always submit a turn-1 message. Steer drives it when present (the brief lives in
|
|
358
|
+
// systemPromptAppend as durable context); with NO steer, fall back to submitting the brief
|
|
359
|
+
// itself so the spawn isn't left with an empty prompt and idling forever.
|
|
360
|
+
prompt: joinBlocks(steer ? stripSentinels(steer) : context),
|
|
356
361
|
};
|
|
357
362
|
}
|
|
358
363
|
return {
|
package/src/spawn-targets.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { countLiveSpawnedAgents, getAgent, getOrchestrator, listOrchestrators, listProviderQuotas, ValidationError } from "./db";
|
|
2
2
|
import { DEFAULT_SPAWN_PROFILE_NAME, listAgentProfiles } from "./config-store";
|
|
3
3
|
import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
4
|
+
import { resolveDefaultModel } from "agent-relay-sdk/provider-catalog";
|
|
4
5
|
import { renderTemplate } from "./prompt-resolver";
|
|
5
6
|
import { getRoutingPolicy } from "./routing-policy-store";
|
|
6
7
|
import { isPathWithinBase } from "./utils";
|
|
@@ -113,6 +114,26 @@ const DEFAULT_QUOTA_ADVISORY: SpawnQuotaAdvisory = {
|
|
|
113
114
|
recommendedAction: "No quota burn projection available; spawn normally.",
|
|
114
115
|
};
|
|
115
116
|
|
|
117
|
+
// #926 — the orchestrator's REPORTED catalog is built from the static SDK manifest only
|
|
118
|
+
// (orchestrator/src/provider-probe.ts uses providerCatalogList(), which has no DB access), while
|
|
119
|
+
// spawn VALIDATION resolves against effectiveProviderCatalogList() (static catalog + DB
|
|
120
|
+
// provider_model_overrides — see resolveSpawnModelParams in spawn-command.ts). A model enabled
|
|
121
|
+
// only via a DB override (e.g. a newly added model) is therefore spawnable but invisible to
|
|
122
|
+
// this matrix even after an orchestrator restart, since the orchestrator's snapshot never reads
|
|
123
|
+
// the DB. Merge by alias, with the DB-matrix (global) entry winning on collision, so every model
|
|
124
|
+
// spawn validation would accept is discoverable here too.
|
|
125
|
+
function mergeCatalogModels(reportedCat: SpawnCatalogish | undefined, globalCat: SpawnCatalogish | undefined): SpawnCatalogish | undefined {
|
|
126
|
+
if (!reportedCat && !globalCat) return undefined;
|
|
127
|
+
const byAlias = new Map<string, SpawnCatalogish["models"][number]>();
|
|
128
|
+
for (const m of reportedCat?.models ?? []) byAlias.set(m.alias, m);
|
|
129
|
+
for (const m of globalCat?.models ?? []) byAlias.set(m.alias, m);
|
|
130
|
+
return {
|
|
131
|
+
provider: (reportedCat ?? globalCat)!.provider,
|
|
132
|
+
defaultModel: reportedCat?.defaultModel ?? globalCat?.defaultModel,
|
|
133
|
+
models: [...byAlias.values()],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
function providerMatrixFor(o: Orchestrator, quotaAdvisories: Map<SpawnProvider, SpawnQuotaAdvisory>): Array<Record<string, unknown>> {
|
|
117
138
|
const reported: ProviderCatalogSummary[] = o.providerCatalog ?? [];
|
|
118
139
|
const global = effectiveProviderCatalogList();
|
|
@@ -129,24 +150,30 @@ function providerMatrixFor(o: Orchestrator, quotaAdvisories: Map<SpawnProvider,
|
|
|
129
150
|
const unavailableAliasesFor = (provider: SpawnProvider): Set<string> =>
|
|
130
151
|
new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.unavailable).map((m) => m.alias) ?? []);
|
|
131
152
|
return o.providers.map((provider) => {
|
|
132
|
-
const
|
|
133
|
-
|
|
153
|
+
const globalEntry = global.find((e) => e.provider === provider);
|
|
154
|
+
const cat = mergeCatalogModels(reported.find((s) => s.provider === provider), globalEntry);
|
|
134
155
|
const disabled = disabledAliasesFor(provider);
|
|
135
156
|
const unavailable = unavailableAliasesFor(provider);
|
|
136
157
|
const quotaAdvisory = quotaAdvisories.get(provider) ?? DEFAULT_QUOTA_ADVISORY;
|
|
158
|
+
// #932 — advertise the SAME default a bare spawn would actually resolve to. resolveDefaultModel
|
|
159
|
+
// runs against the effective (DB-override-aware) catalog — the exact source spawn validation
|
|
160
|
+
// (resolveProviderSelection) reads — so the reported default can never drift from the spawned
|
|
161
|
+
// one and can never name a disabled/unavailable model. Fall back to the merged catalog's static
|
|
162
|
+
// default only when the provider is absent from the effective list (should not happen).
|
|
163
|
+
const resolvedDefault = globalEntry ? resolveDefaultModel(globalEntry).alias : cat?.defaultModel;
|
|
137
164
|
return {
|
|
138
165
|
provider,
|
|
139
166
|
available: true,
|
|
140
167
|
...quotaAdvisory,
|
|
141
168
|
...(cat
|
|
142
169
|
? {
|
|
143
|
-
defaultModel:
|
|
170
|
+
defaultModel: resolvedDefault,
|
|
144
171
|
models: cat.models
|
|
145
172
|
.filter((m) => !m.disabled && !disabled.has(m.alias) && !m.unavailable && !unavailable.has(m.alias))
|
|
146
173
|
.map((m) => ({
|
|
147
174
|
id: m.alias,
|
|
148
175
|
providerModel: m.providerModel,
|
|
149
|
-
default:
|
|
176
|
+
default: resolvedDefault === m.alias,
|
|
150
177
|
effortLevels: m.efforts,
|
|
151
178
|
...(m.defaultEffort ? { defaultEffort: m.defaultEffort } : {}),
|
|
152
179
|
})),
|