@cjhyy/code-shell-server 0.9.8 → 0.9.10
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/README.md +85 -23
- package/dist/bin/code-shell-project-runtime.d.ts +2 -0
- package/dist/bin/code-shell-project-runtime.js +8 -0
- package/dist/desktop-web/http-api.d.ts +43 -0
- package/dist/desktop-web/http-api.js +376 -0
- package/dist/hub/approval-lease.d.ts +30 -0
- package/dist/hub/approval-lease.js +89 -0
- package/dist/hub/auth-http.d.ts +23 -0
- package/dist/hub/auth-http.js +259 -0
- package/dist/hub/auth-store.d.ts +52 -0
- package/dist/hub/auth-store.js +316 -0
- package/dist/hub/configuration.d.ts +64 -0
- package/dist/hub/configuration.js +555 -0
- package/dist/hub/files.d.ts +9 -0
- package/dist/hub/files.js +278 -0
- package/dist/hub/mcp-configuration.d.ts +51 -0
- package/dist/hub/mcp-configuration.js +558 -0
- package/dist/hub/mcp-probe.d.ts +24 -0
- package/dist/hub/mcp-probe.js +392 -0
- package/dist/hub/model-probe.d.ts +20 -0
- package/dist/hub/model-probe.js +193 -0
- package/dist/hub/outbound-transport.d.ts +36 -0
- package/dist/hub/outbound-transport.js +122 -0
- package/dist/hub/run-replay.d.ts +42 -0
- package/dist/hub/run-replay.js +89 -0
- package/dist/hub/session-management.d.ts +36 -0
- package/dist/hub/session-management.js +405 -0
- package/dist/hub/skills-management.d.ts +43 -0
- package/dist/hub/skills-management.js +487 -0
- package/dist/hub/uploads.d.ts +22 -0
- package/dist/hub/uploads.js +241 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.desktop-web.d.ts +2 -0
- package/dist/index.desktop-web.js +2 -0
- package/dist/index.js +3 -0
- package/dist/index.links.d.ts +6 -0
- package/dist/index.links.js +6 -0
- package/dist/index.panels.d.ts +9 -0
- package/dist/index.panels.js +8 -0
- package/dist/index.serve.d.ts +1 -0
- package/dist/index.serve.js +1 -0
- package/dist/index.storage.d.ts +2 -0
- package/dist/index.storage.js +2 -0
- package/dist/links/catalog.d.ts +10 -0
- package/dist/links/catalog.js +41 -0
- package/dist/links/device-oauth.d.ts +52 -0
- package/dist/links/device-oauth.js +255 -0
- package/dist/links/http.d.ts +34 -0
- package/dist/links/http.js +144 -0
- package/dist/links/service.d.ts +54 -0
- package/dist/links/service.js +578 -0
- package/dist/links/types.d.ts +6 -0
- package/dist/links/types.js +1 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +5 -2
- package/dist/mobile-remote/pending-approvals.js +2 -0
- package/dist/mobile-remote/remote-host-manager.d.ts +8 -0
- package/dist/mobile-remote/remote-host-manager.js +90 -15
- package/dist/mobile-remote/room-manager.js +17 -5
- package/dist/panels/agent-task-host.d.ts +32 -0
- package/dist/panels/agent-task-host.js +457 -0
- package/dist/panels/agent-task-models.d.ts +27 -0
- package/dist/panels/agent-task-models.js +51 -0
- package/dist/panels/agent-task-service.d.ts +105 -0
- package/dist/panels/agent-task-service.js +283 -0
- package/dist/panels/http.d.ts +34 -0
- package/dist/panels/http.js +37 -0
- package/dist/panels/hub-binding.d.ts +12 -0
- package/dist/panels/hub-binding.js +174 -0
- package/dist/panels/management-http.d.ts +29 -0
- package/dist/panels/management-http.js +119 -0
- package/dist/panels/management.d.ts +39 -0
- package/dist/panels/management.js +494 -0
- package/dist/panels/process-files.d.ts +14 -0
- package/dist/panels/process-files.js +299 -0
- package/dist/panels/process-service.d.ts +96 -0
- package/dist/panels/process-service.js +501 -0
- package/dist/panels/runtime-services.d.ts +24 -0
- package/dist/panels/runtime-services.js +462 -0
- package/dist/panels/runtime.d.ts +51 -0
- package/dist/panels/runtime.js +1063 -0
- package/dist/panels/storage-store.d.ts +9 -0
- package/dist/panels/storage-store.js +154 -0
- package/dist/panels/types.d.ts +52 -0
- package/dist/panels/types.js +1 -0
- package/dist/project-runtime/control-server.d.ts +19 -0
- package/dist/project-runtime/control-server.js +300 -0
- package/dist/project-runtime/docker-provider.d.ts +33 -0
- package/dist/project-runtime/docker-provider.js +487 -0
- package/dist/project-runtime/lock.d.ts +6 -0
- package/dist/project-runtime/lock.js +43 -0
- package/dist/project-runtime/managed-entry.d.ts +10 -0
- package/dist/project-runtime/managed-entry.js +113 -0
- package/dist/project-runtime/manager.d.ts +40 -0
- package/dist/project-runtime/manager.js +249 -0
- package/dist/project-runtime/proxy.d.ts +22 -0
- package/dist/project-runtime/proxy.js +706 -0
- package/dist/project-runtime/registry.d.ts +54 -0
- package/dist/project-runtime/registry.js +286 -0
- package/dist/project-runtime/types.d.ts +43 -0
- package/dist/project-runtime/types.js +1 -0
- package/dist/serve/cli.d.ts +5 -0
- package/dist/serve/cli.js +82 -23
- package/dist/serve/headless-server.d.ts +11 -0
- package/dist/serve/headless-server.js +737 -83
- package/dist/session-titles-store.d.ts +17 -0
- package/dist/session-titles-store.js +174 -0
- package/dist/sessions-service.d.ts +2 -0
- package/dist/sessions-service.js +8 -0
- package/dist/static-files.js +1 -0
- package/dist/worker-bridge-core.js +17 -6
- package/package.json +19 -5
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
// packages/server/src/serve/headless-server.ts
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Composition, all pre-existing parts:
|
|
7
|
-
// - AccessPasscode HTTP gate (challenge page) + WS `allows()`;
|
|
8
|
-
// passcode + remember-cookie is the ONLY access
|
|
9
|
-
// control — no AuthN/AuthZ, no users, by decision.
|
|
10
|
-
// - WorkerBridgeCore spawns/drives ONE agent-server-stdio worker.
|
|
11
|
-
// - resolveSafe path-traversal-safe static file resolution.
|
|
3
|
+
// Single-workspace Node host. The CLI uses Hub administrator sessions; the
|
|
4
|
+
// legacy passcode mode remains available to existing embedders.
|
|
5
|
+
// One WorkerBridgeCore owns execution and the persistent workspace sessions.
|
|
12
6
|
//
|
|
13
7
|
// The browser uses a deliberately small CORE-protocol projection: the host
|
|
14
8
|
// answers session list/detail itself and forwards only run/approve/cancel after
|
|
@@ -20,18 +14,39 @@
|
|
|
20
14
|
// browser re-lists sessions over the same protocol.
|
|
21
15
|
import { createServer } from "node:http";
|
|
22
16
|
import { readFileSync, existsSync } from "node:fs";
|
|
23
|
-
import { dirname, extname, join, resolve } from "node:path";
|
|
24
|
-
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
18
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
25
19
|
import { WebSocketServer } from "ws";
|
|
26
|
-
import { SessionManager } from "@cjhyy/code-shell-core";
|
|
20
|
+
import { SessionManager, SettingsManager } from "@cjhyy/code-shell-core";
|
|
27
21
|
import { AccessPasscode } from "../mobile-remote/access-passcode.js";
|
|
28
22
|
import { resolveSafe } from "../mobile-remote/mobile-static.js";
|
|
29
23
|
import { contentTypeFor } from "../static-files.js";
|
|
24
|
+
import { createHubAuth } from "../hub/auth-http.js";
|
|
25
|
+
import { ApprovalLeases } from "../hub/approval-lease.js";
|
|
26
|
+
import { HubUploads, hubJson } from "../hub/uploads.js";
|
|
27
|
+
import { createHubConfiguration, HubConfigurationError } from "../hub/configuration.js";
|
|
28
|
+
import { HubRunReplay } from "../hub/run-replay.js";
|
|
29
|
+
import { HubOutboundTransport } from "../hub/outbound-transport.js";
|
|
30
|
+
import { createHubSkills } from "../hub/skills-management.js";
|
|
31
|
+
import { createHubMcpConfiguration } from "../hub/mcp-configuration.js";
|
|
32
|
+
import { createLinkHttp } from "../links/http.js";
|
|
33
|
+
import { createPanelHttp } from "../panels/http.js";
|
|
34
|
+
import { createHubPanelBinding } from "../panels/hub-binding.js";
|
|
35
|
+
import { createHubSessions, hubSessionPreview, readHubSessionState, readHubTranscript, } from "../hub/session-management.js";
|
|
36
|
+
import { createHubFiles } from "../hub/files.js";
|
|
30
37
|
import { WorkerBridgeCore, previewLine } from "../worker-bridge-core.js";
|
|
31
38
|
export async function startHeadlessServer(opts) {
|
|
32
39
|
const host = opts.host ?? "127.0.0.1";
|
|
33
|
-
const log =
|
|
40
|
+
const log = (event, data) => {
|
|
41
|
+
if (!opts.log)
|
|
42
|
+
return;
|
|
43
|
+
// Transport diagnostics must not write prompts, approval answers or worker
|
|
44
|
+
// stderr previews to default service logs. Keep only structural metadata.
|
|
45
|
+
const { raw: _raw, text: _text, ...metadata } = data ?? {};
|
|
46
|
+
opts.log(event, opts.debugLogs ? data : metadata);
|
|
47
|
+
};
|
|
34
48
|
const workspaceCwd = resolve(opts.cwd);
|
|
49
|
+
const panelBinding = opts.authMode === "hub" ? createHubPanelBinding(workspaceCwd) : undefined;
|
|
35
50
|
const workerDataRoot = resolve(opts.workerDataRoot ??
|
|
36
51
|
(opts.sessionRootDir ? dirname(resolve(opts.sessionRootDir)) : join(opts.dataDir, "worker")));
|
|
37
52
|
const sessionRootDir = resolve(opts.sessionRootDir ?? join(workerDataRoot, "sessions"));
|
|
@@ -50,40 +65,95 @@ export async function startHeadlessServer(opts) {
|
|
|
50
65
|
}
|
|
51
66
|
const passcode = new AccessPasscode({ filePath: join(opts.dataDir, "access.json") });
|
|
52
67
|
let generatedPasscode;
|
|
53
|
-
if (opts.passcode) {
|
|
68
|
+
if (opts.authMode !== "hub" && opts.passcode) {
|
|
54
69
|
passcode.set(opts.passcode);
|
|
55
70
|
}
|
|
56
|
-
else if (!passcode.isSet()) {
|
|
71
|
+
else if (opts.authMode !== "hub" && !passcode.isSet()) {
|
|
57
72
|
generatedPasscode = randomBytes(6).toString("base64url");
|
|
58
73
|
passcode.set(generatedPasscode);
|
|
59
74
|
}
|
|
60
75
|
const tabs = new Set();
|
|
76
|
+
// Auth revocation callbacks are registered before configuration modules are ready.
|
|
77
|
+
// eslint-disable-next-line prefer-const
|
|
78
|
+
let mcp;
|
|
79
|
+
// eslint-disable-next-line prefer-const
|
|
80
|
+
let configuration;
|
|
81
|
+
// eslint-disable-next-line prefer-const
|
|
82
|
+
let links;
|
|
83
|
+
// eslint-disable-next-line prefer-const
|
|
84
|
+
let panels;
|
|
85
|
+
const tabAuth = new Map();
|
|
86
|
+
const hubAuth = opts.authMode === "hub"
|
|
87
|
+
? await createHubAuth({
|
|
88
|
+
dataDir: opts.dataDir,
|
|
89
|
+
publicOrigin: opts.publicOrigin,
|
|
90
|
+
onRevoke: (sessionId) => {
|
|
91
|
+
mcp?.cancelOwner(sessionId);
|
|
92
|
+
configuration?.cancelOwner(sessionId);
|
|
93
|
+
links?.cancelOwner(sessionId);
|
|
94
|
+
panels?.cancelOwner(sessionId);
|
|
95
|
+
for (const [ws, auth] of tabAuth) {
|
|
96
|
+
if (auth.sessionId === sessionId)
|
|
97
|
+
ws.close(4401, "session revoked");
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
: undefined;
|
|
102
|
+
const uploads = hubAuth ? new HubUploads(join(opts.dataDir, "uploads"), workspaceCwd) : undefined;
|
|
103
|
+
await uploads?.ready();
|
|
61
104
|
const pendingWorkerResponses = new Map();
|
|
62
105
|
const pendingResponsesByTab = new Map();
|
|
106
|
+
// Independent of response routing: leaving a tab does not stop its worker run.
|
|
107
|
+
const runningSessions = new Map();
|
|
108
|
+
const runOwners = new Map();
|
|
109
|
+
const runReplay = new HubRunReplay();
|
|
110
|
+
const runInputs = new Map();
|
|
111
|
+
let preparingRuns = 0;
|
|
112
|
+
let configurationChanging = false;
|
|
113
|
+
let configurationReloadFailed = false;
|
|
114
|
+
const runUploads = new Map();
|
|
115
|
+
const isRunning = (sessionId) => [...runningSessions.values()].includes(sessionId);
|
|
63
116
|
let nextTabId = 1;
|
|
64
117
|
let nextWorkerRequestId = 1;
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return true;
|
|
71
|
-
}
|
|
72
|
-
catch (error) {
|
|
73
|
-
// readyState can change between the guard and send. A dead browser must
|
|
74
|
-
// never crash the shared worker router or its timeout reaper.
|
|
75
|
-
log("tab.send_failed", {
|
|
76
|
-
context,
|
|
77
|
-
error: error instanceof Error ? error.message : String(error),
|
|
78
|
-
});
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
};
|
|
118
|
+
const outbound = new HubOutboundTransport({
|
|
119
|
+
tabs: () => tabs,
|
|
120
|
+
onDrop: (metadata) => log("tab.backpressure_drop", { ...metadata }),
|
|
121
|
+
});
|
|
122
|
+
const sendToTab = (tab, line, _context) => outbound.send(tab, line);
|
|
82
123
|
const broadcast = (line) => {
|
|
83
124
|
for (const tab of tabs) {
|
|
84
125
|
sendToTab(tab, line, "broadcast");
|
|
85
126
|
}
|
|
86
127
|
};
|
|
128
|
+
const notify = (method, params) => {
|
|
129
|
+
broadcast(JSON.stringify({ jsonrpc: "2.0", method, params }));
|
|
130
|
+
};
|
|
131
|
+
const publishStream = (sessionId, event) => {
|
|
132
|
+
const cursor = runReplay.append(sessionId, event);
|
|
133
|
+
notify("agent/streamEvent", {
|
|
134
|
+
sessionId,
|
|
135
|
+
event,
|
|
136
|
+
hubSequence: cursor.sequence,
|
|
137
|
+
hubEpoch: cursor.epoch,
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
const leases = new ApprovalLeases((lease) => notify("serve/approvalLease", lease));
|
|
141
|
+
const authReaper = hubAuth
|
|
142
|
+
? setInterval(() => {
|
|
143
|
+
leases.sweep();
|
|
144
|
+
void uploads?.sweep().catch(() => { });
|
|
145
|
+
for (const [ws, auth] of tabAuth) {
|
|
146
|
+
void hubAuth
|
|
147
|
+
.authenticate(auth.request)
|
|
148
|
+
.then((session) => {
|
|
149
|
+
if (!session)
|
|
150
|
+
ws.close(4401, "session expired");
|
|
151
|
+
})
|
|
152
|
+
.catch(() => ws.close(4401, "session unavailable"));
|
|
153
|
+
}
|
|
154
|
+
}, opts.authRecheckMs ?? 5_000)
|
|
155
|
+
: undefined;
|
|
156
|
+
authReaper?.unref?.();
|
|
87
157
|
const deletePendingWorkerResponse = (requestId) => {
|
|
88
158
|
const route = pendingWorkerResponses.get(requestId);
|
|
89
159
|
if (!route)
|
|
@@ -97,7 +167,9 @@ export async function startHeadlessServer(opts) {
|
|
|
97
167
|
};
|
|
98
168
|
const clearPendingWorkerResponsesForTab = (tabId) => {
|
|
99
169
|
for (const [requestId, route] of pendingWorkerResponses) {
|
|
100
|
-
|
|
170
|
+
// A submitted approval still needs its worker ACK after this tab leaves.
|
|
171
|
+
// In particular, an error must reopen the card on the remaining devices.
|
|
172
|
+
if (route.tabId === tabId && !route.approvalId)
|
|
101
173
|
deletePendingWorkerResponse(requestId);
|
|
102
174
|
}
|
|
103
175
|
};
|
|
@@ -107,10 +179,20 @@ export async function startHeadlessServer(opts) {
|
|
|
107
179
|
}
|
|
108
180
|
pendingWorkerResponses.clear();
|
|
109
181
|
pendingResponsesByTab.clear();
|
|
182
|
+
runningSessions.clear();
|
|
183
|
+
runOwners.clear();
|
|
184
|
+
runReplay.clear();
|
|
185
|
+
runInputs.clear();
|
|
186
|
+
for (const receipt of runUploads.values())
|
|
187
|
+
receipt.release();
|
|
188
|
+
runUploads.clear();
|
|
110
189
|
};
|
|
111
190
|
const pendingResponseReaper = setInterval(() => {
|
|
112
191
|
const now = Date.now();
|
|
113
192
|
for (const [requestId, route] of pendingWorkerResponses) {
|
|
193
|
+
// Runs answer only at turn end, potentially hours later.
|
|
194
|
+
if (hubAuth && (route.method === "agent/run" || route.approvalId))
|
|
195
|
+
continue;
|
|
114
196
|
if (now - route.insertedAt < pendingResponseTtlMs)
|
|
115
197
|
continue;
|
|
116
198
|
deletePendingWorkerResponse(requestId);
|
|
@@ -132,17 +214,132 @@ export async function startHeadlessServer(opts) {
|
|
|
132
214
|
return;
|
|
133
215
|
}
|
|
134
216
|
if (!message || message.id === undefined || message.method !== undefined) {
|
|
217
|
+
if (hubAuth && message?.method === "agent/streamEvent") {
|
|
218
|
+
const params = message.params;
|
|
219
|
+
if (typeof params?.sessionId === "string" && params.event !== undefined) {
|
|
220
|
+
let event = params.event;
|
|
221
|
+
const value = event;
|
|
222
|
+
if (value?.type === "session_user_message" && typeof value.clientMessageId === "string") {
|
|
223
|
+
const input = [...runInputs.values()].find((candidate) => candidate.sessionId === params.sessionId &&
|
|
224
|
+
candidate.event.clientMessageId === value.clientMessageId);
|
|
225
|
+
if (input) {
|
|
226
|
+
if (input.echoed)
|
|
227
|
+
return;
|
|
228
|
+
input.echoed = true;
|
|
229
|
+
event = input.event;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
publishStream(params.sessionId, event);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (hubAuth && message?.method === "agent/runAccepted") {
|
|
237
|
+
const params = message.params;
|
|
238
|
+
const requestId = String(params?.requestId);
|
|
239
|
+
const input = runInputs.get(requestId);
|
|
240
|
+
if (input && !input.echoed) {
|
|
241
|
+
input.echoed = true;
|
|
242
|
+
publishStream(input.sessionId, input.event);
|
|
243
|
+
}
|
|
244
|
+
const route = pendingWorkerResponses.get(requestId);
|
|
245
|
+
if (route)
|
|
246
|
+
sendToTab(route.tab, JSON.stringify({
|
|
247
|
+
...message,
|
|
248
|
+
params: { ...params, requestId: route.originalId },
|
|
249
|
+
}), "run-accepted");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (hubAuth && message?.method === "agent/approvalRequest") {
|
|
253
|
+
const approval = message.params;
|
|
254
|
+
if (approval &&
|
|
255
|
+
typeof approval.requestId === "string" &&
|
|
256
|
+
typeof approval.sessionId === "string") {
|
|
257
|
+
const request = approval.request;
|
|
258
|
+
if (request?.toolName === "__panel_action__") {
|
|
259
|
+
const ownerId = runOwners.get(approval.sessionId);
|
|
260
|
+
const result = ownerId && panels
|
|
261
|
+
? panels.panelAction(ownerId, approval.sessionId, request.args ?? {})
|
|
262
|
+
: Promise.resolve({ ok: false, detail: "当前对话没有可用的面板连接。" });
|
|
263
|
+
void result.catch(() => ({ ok: false, detail: "面板操作未完成。" })).then((answer) => {
|
|
264
|
+
if (!isRunning(approval.sessionId) || runOwners.get(approval.sessionId) !== ownerId)
|
|
265
|
+
return;
|
|
266
|
+
return bridge.request("agent/approve", {
|
|
267
|
+
sessionId: approval.sessionId, requestId: approval.requestId,
|
|
268
|
+
connectionId: approval.connectionId, generation: approval.generation,
|
|
269
|
+
decision: { approved: true, answer: JSON.stringify(answer) },
|
|
270
|
+
}, { id: `panel-reply-${randomUUID()}`, consume: true, settleOnExit: true, failFast: true, timeoutMs: 5000,
|
|
271
|
+
meta: { origin: "host", producer: "hub-panel-action" },
|
|
272
|
+
});
|
|
273
|
+
}).catch(() => { });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
leases.add(approval);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
workerUnavailable("pending approval limit exceeded");
|
|
281
|
+
bridge.kill();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else if (hubAuth && message?.method === "agent/approvalResolved") {
|
|
287
|
+
const requestId = message.params?.requestId;
|
|
288
|
+
if (requestId)
|
|
289
|
+
leases.resolved(requestId);
|
|
290
|
+
}
|
|
135
291
|
broadcast(line);
|
|
136
292
|
return;
|
|
137
293
|
}
|
|
294
|
+
const receipt = runUploads.get(String(message.id));
|
|
295
|
+
if (receipt) {
|
|
296
|
+
runUploads.delete(String(message.id));
|
|
297
|
+
if (message.error)
|
|
298
|
+
receipt.release();
|
|
299
|
+
else
|
|
300
|
+
void receipt.commit().catch(() => log("upload.finalize_failed"));
|
|
301
|
+
}
|
|
302
|
+
const runSessionId = runningSessions.get(String(message.id));
|
|
303
|
+
runInputs.delete(String(message.id));
|
|
304
|
+
if (runSessionId) {
|
|
305
|
+
const finishedSession = runningSessions.get(String(message.id));
|
|
306
|
+
runningSessions.delete(String(message.id));
|
|
307
|
+
if (finishedSession && !isRunning(finishedSession))
|
|
308
|
+
runOwners.delete(finishedSession);
|
|
309
|
+
runReplay.finish(runSessionId, String(message.id));
|
|
310
|
+
if (hubAuth)
|
|
311
|
+
notify("serve/sessionStatus", {
|
|
312
|
+
sessionId: runSessionId,
|
|
313
|
+
running: isRunning(runSessionId),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
138
316
|
const route = pendingWorkerResponses.get(String(message.id));
|
|
139
317
|
if (!route) {
|
|
140
318
|
log("worker.response_dropped", { reason: "unknown request id", id: message.id });
|
|
141
319
|
return;
|
|
142
320
|
}
|
|
321
|
+
if (route.approvalId) {
|
|
322
|
+
if (message.error) {
|
|
323
|
+
leases.retry(route.approvalId);
|
|
324
|
+
notify("serve/approvalSnapshot", { approvals: leases.snapshot() });
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// The worker's successful ACK is authoritative even if an implementation
|
|
328
|
+
// omits the separate approvalResolved notification.
|
|
329
|
+
leases.resolved(route.approvalId);
|
|
330
|
+
notify("agent/approvalResolved", { requestId: route.approvalId });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
143
333
|
deletePendingWorkerResponse(String(message.id));
|
|
144
334
|
sendToTab(route.tab, JSON.stringify({ ...message, id: route.originalId }), "worker-response");
|
|
145
335
|
};
|
|
336
|
+
const workerUnavailable = (message) => {
|
|
337
|
+
failPendingWorkerResponses(message);
|
|
338
|
+
leases.clear();
|
|
339
|
+
if (hubAuth)
|
|
340
|
+
notify("serve/approvalSnapshot", { approvals: [] });
|
|
341
|
+
notify("serve/workerExit", { clean: false, gaveUp: true });
|
|
342
|
+
};
|
|
146
343
|
const bridge = new WorkerBridgeCore({
|
|
147
344
|
entryPath: opts.workerEntryPath,
|
|
148
345
|
execPath: opts.execPath,
|
|
@@ -150,14 +347,21 @@ export async function startHeadlessServer(opts) {
|
|
|
150
347
|
buildEnv: () => ({
|
|
151
348
|
...process.env,
|
|
152
349
|
CODE_SHELL_DATA_ROOT: workerDataRoot,
|
|
350
|
+
CODE_SHELL_CREDENTIAL_ACCESS: "local",
|
|
153
351
|
...(opts.workerCapabilityModules
|
|
154
352
|
? { CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules }
|
|
155
353
|
: {}),
|
|
156
354
|
}),
|
|
157
355
|
log,
|
|
158
356
|
onStderr: (text) => log("worker.stderr", { text: previewLine(text) }),
|
|
357
|
+
onSpawnFailed: () => workerUnavailable("agent worker could not start"),
|
|
358
|
+
onSpawnError: () => workerUnavailable("agent worker could not start"),
|
|
159
359
|
onExit: (info) => {
|
|
160
360
|
failPendingWorkerResponses("agent worker stopped");
|
|
361
|
+
leases.clear();
|
|
362
|
+
if (hubAuth)
|
|
363
|
+
notify("serve/approvalSnapshot", { approvals: [] });
|
|
364
|
+
configurationReloadFailed = false;
|
|
161
365
|
// Synthetic serve-level notification so the UI can show "agent worker
|
|
162
366
|
// stopped" without conflating it with in-protocol agent/status.
|
|
163
367
|
broadcast(JSON.stringify({
|
|
@@ -168,6 +372,102 @@ export async function startHeadlessServer(opts) {
|
|
|
168
372
|
},
|
|
169
373
|
});
|
|
170
374
|
bridge.subscribeLines(routeWorkerLine);
|
|
375
|
+
const withConfigurationMutation = async (write) => {
|
|
376
|
+
if (configurationChanging ||
|
|
377
|
+
preparingRuns > 0 ||
|
|
378
|
+
runningSessions.size > 0 ||
|
|
379
|
+
(panels?.activeTaskCount() ?? 0) > 0 ||
|
|
380
|
+
pendingWorkerResponses.size > 0) {
|
|
381
|
+
throw new HubConfigurationError(409, "请等待当前任务完成后再保存配置。");
|
|
382
|
+
}
|
|
383
|
+
configurationChanging = true;
|
|
384
|
+
try {
|
|
385
|
+
const result = await write();
|
|
386
|
+
// Use Desktop's fixed hot-reload operation, preserving sessions and
|
|
387
|
+
// background commands. This host-only RPC is never browser-controlled.
|
|
388
|
+
if (bridge.hasChild()) {
|
|
389
|
+
const outcome = await bridge.request("agent/configure", {
|
|
390
|
+
reloadModels: true,
|
|
391
|
+
reloadSettings: true,
|
|
392
|
+
}, {
|
|
393
|
+
id: `hub-configuration-${nextWorkerRequestId++}`,
|
|
394
|
+
timeoutMs: 5_000,
|
|
395
|
+
consume: true,
|
|
396
|
+
settleOnExit: true,
|
|
397
|
+
failFast: true,
|
|
398
|
+
meta: { origin: "serve", producer: "serve-configuration" },
|
|
399
|
+
});
|
|
400
|
+
if (outcome.status !== "result") {
|
|
401
|
+
configurationReloadFailed = bridge.hasChild();
|
|
402
|
+
throw new HubConfigurationError(503, "配置已保存但未能生效,请重新保存或重启服务。");
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
configurationReloadFailed = false;
|
|
406
|
+
notify("serve/configurationChanged", {});
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
configurationChanging = false;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const configurationOptions = {
|
|
414
|
+
cwd: workspaceCwd,
|
|
415
|
+
isAuthorized: async (req) => !!(await hubAuth?.authenticate(req)),
|
|
416
|
+
ownerId: async (req) => (await hubAuth?.authenticate(req))?.id,
|
|
417
|
+
withMutation: withConfigurationMutation,
|
|
418
|
+
};
|
|
419
|
+
configuration = hubAuth ? createHubConfiguration(configurationOptions) : undefined;
|
|
420
|
+
links = hubAuth
|
|
421
|
+
? createLinkHttp({
|
|
422
|
+
...configurationOptions,
|
|
423
|
+
onChanged: () => notify("serve/configurationChanged", {}),
|
|
424
|
+
})
|
|
425
|
+
: undefined;
|
|
426
|
+
panels = hubAuth
|
|
427
|
+
? createPanelHttp({
|
|
428
|
+
...configurationOptions,
|
|
429
|
+
...panelBinding,
|
|
430
|
+
dataDir: opts.dataDir,
|
|
431
|
+
host: "hub",
|
|
432
|
+
publicPathPrefix: opts.publicPathPrefix,
|
|
433
|
+
agentTaskOptions: {
|
|
434
|
+
workerEntryPath: opts.workerEntryPath,
|
|
435
|
+
buildEnv: () => ({
|
|
436
|
+
...process.env,
|
|
437
|
+
CODE_SHELL_DATA_ROOT: workerDataRoot,
|
|
438
|
+
CODE_SHELL_CREDENTIAL_ACCESS: "local",
|
|
439
|
+
...(opts.workerCapabilityModules ? { CODE_SHELL_CAPABILITY_MODULES: opts.workerCapabilityModules } : {}),
|
|
440
|
+
}),
|
|
441
|
+
},
|
|
442
|
+
onChanged: () => notify("serve/configurationChanged", {}),
|
|
443
|
+
})
|
|
444
|
+
: undefined;
|
|
445
|
+
const skills = hubAuth
|
|
446
|
+
? createHubSkills({
|
|
447
|
+
...configurationOptions,
|
|
448
|
+
dataDir: opts.dataDir,
|
|
449
|
+
owner: async (req) => (await hubAuth.authenticate(req))?.id ?? null,
|
|
450
|
+
})
|
|
451
|
+
: undefined;
|
|
452
|
+
mcp = hubAuth
|
|
453
|
+
? createHubMcpConfiguration({
|
|
454
|
+
...configurationOptions,
|
|
455
|
+
ownerId: async (req) => (await hubAuth.authenticate(req))?.id,
|
|
456
|
+
})
|
|
457
|
+
: undefined;
|
|
458
|
+
const sessionManagement = hubAuth
|
|
459
|
+
? createHubSessions({
|
|
460
|
+
cwd: workspaceCwd,
|
|
461
|
+
sessionRootDir,
|
|
462
|
+
dataDir: opts.dataDir,
|
|
463
|
+
isAuthorized: configurationOptions.isAuthorized,
|
|
464
|
+
isRunning,
|
|
465
|
+
onChanged: (sessionId) => notify("serve/sessionsChanged", { sessionId }),
|
|
466
|
+
})
|
|
467
|
+
: undefined;
|
|
468
|
+
const files = hubAuth
|
|
469
|
+
? createHubFiles({ cwd: workspaceCwd, isAuthorized: configurationOptions.isAuthorized })
|
|
470
|
+
: undefined;
|
|
171
471
|
const serveStatic = (req, res) => {
|
|
172
472
|
const root = opts.staticRootDir;
|
|
173
473
|
if (!root) {
|
|
@@ -212,27 +512,105 @@ export async function startHeadlessServer(opts) {
|
|
|
212
512
|
res.end("not found");
|
|
213
513
|
}
|
|
214
514
|
};
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
515
|
+
const handleHttp = async (req, res) => {
|
|
516
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
517
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
518
|
+
res.setHeader("referrer-policy", "no-referrer");
|
|
519
|
+
if (pathname === "/health" && req.method === "GET") {
|
|
520
|
+
hubJson(res, 200, { status: "ok", mode: hubAuth ? "hub" : "passcode" });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (hubAuth) {
|
|
524
|
+
if (await panels?.handleAssets(req, res))
|
|
525
|
+
return;
|
|
526
|
+
if (await hubAuth.handle(req, res))
|
|
527
|
+
return;
|
|
528
|
+
if (pathname.startsWith("/api/")) {
|
|
529
|
+
const session = await hubAuth.authenticate(req);
|
|
530
|
+
if (!session) {
|
|
531
|
+
hubJson(res, 401, { error: "login required" });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (!hubAuth.isOriginAllowed(req)) {
|
|
535
|
+
hubJson(res, 403, { error: "origin rejected" });
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (await configuration.handle(req, res))
|
|
539
|
+
return;
|
|
540
|
+
if (await links.handle(req, res))
|
|
541
|
+
return;
|
|
542
|
+
if (await panels.handle(req, res))
|
|
543
|
+
return;
|
|
544
|
+
if (await skills.handle(req, res))
|
|
545
|
+
return;
|
|
546
|
+
if (await mcp.handle(req, res))
|
|
547
|
+
return;
|
|
548
|
+
if (await sessionManagement.handle(req, res))
|
|
549
|
+
return;
|
|
550
|
+
if (await files.handle(req, res))
|
|
551
|
+
return;
|
|
552
|
+
const upload = /^\/api\/v1\/uploads\/([^/]+)$/.exec(pathname);
|
|
553
|
+
if (upload && req.method === "PUT") {
|
|
554
|
+
await uploads.accept(upload[1], session.id, req, res, async () => !!(await hubAuth.authenticate(req)));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
hubJson(res, 404, { error: "API endpoint not found" });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
561
|
+
hubJson(res, 405, { error: "method not allowed" });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
// Public static shell contains no user data and supplies the setup/login page.
|
|
565
|
+
serveStatic(req, res);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
219
568
|
if (!passcode.gate(req, res))
|
|
220
569
|
return;
|
|
221
570
|
serveStatic(req, res);
|
|
571
|
+
};
|
|
572
|
+
const server = createServer((req, res) => {
|
|
573
|
+
void handleHttp(req, res).catch(() => hubJson(res, 500, { error: "server request failed" }));
|
|
222
574
|
});
|
|
223
575
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
224
|
-
|
|
576
|
+
const upgrade = async (req, socket, head) => {
|
|
225
577
|
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
226
|
-
|
|
578
|
+
const session = hubAuth ? await hubAuth.authenticate(req) : undefined;
|
|
579
|
+
const allowed = hubAuth
|
|
580
|
+
? !!session && hubAuth.isOriginAllowed(req)
|
|
581
|
+
: passcode.allows(req);
|
|
582
|
+
if (pathname !== "/ws" || !allowed) {
|
|
227
583
|
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
|
228
584
|
socket.destroy();
|
|
229
585
|
return;
|
|
230
586
|
}
|
|
587
|
+
if (tabs.size >= 64) {
|
|
588
|
+
socket.write("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n");
|
|
589
|
+
socket.destroy();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
231
592
|
const tabId = nextTabId++;
|
|
232
593
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
233
594
|
tabs.add(ws);
|
|
595
|
+
if (session)
|
|
596
|
+
tabAuth.set(ws, { sessionId: session.id, request: req });
|
|
597
|
+
const holderId = String(tabId);
|
|
598
|
+
if (hubAuth) {
|
|
599
|
+
sendToTab(ws, JSON.stringify({ jsonrpc: "2.0", method: "serve/hello", params: { tabId: holderId } }), "hello");
|
|
600
|
+
sendToTab(ws, JSON.stringify({
|
|
601
|
+
jsonrpc: "2.0",
|
|
602
|
+
method: "serve/approvalSnapshot",
|
|
603
|
+
params: { approvals: leases.snapshot() },
|
|
604
|
+
}), "approval-snapshot");
|
|
605
|
+
}
|
|
234
606
|
log("tab.connected", { tabs: tabs.size });
|
|
235
|
-
|
|
607
|
+
const handleMessage = async (data) => {
|
|
608
|
+
if (ws.readyState !== ws.OPEN)
|
|
609
|
+
return;
|
|
610
|
+
if (hubAuth && !(await hubAuth.authenticate(req))) {
|
|
611
|
+
ws.close(4401, "session expired");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
236
614
|
if (webSocketPayloadBytes(data) > 1024 * 1024) {
|
|
237
615
|
log("tab.frame_dropped", { reason: "payload too large" });
|
|
238
616
|
ws.close(1009, "payload too large");
|
|
@@ -253,57 +631,273 @@ export async function startHeadlessServer(opts) {
|
|
|
253
631
|
log("tab.frame_dropped", { reason: "not jsonrpc", raw: previewLine(line) });
|
|
254
632
|
return;
|
|
255
633
|
}
|
|
256
|
-
|
|
634
|
+
if (hubAuth &&
|
|
635
|
+
(parsed.method === "serve/approval.claim" || parsed.method === "serve/approval.release")) {
|
|
636
|
+
const requestId = parsed.params?.requestId;
|
|
637
|
+
try {
|
|
638
|
+
if (typeof requestId !== "string")
|
|
639
|
+
throw new Error("requestId required");
|
|
640
|
+
const result = parsed.method === "serve/approval.claim"
|
|
641
|
+
? leases.claim(requestId, holderId)
|
|
642
|
+
: (leases.release(requestId, holderId), { released: true });
|
|
643
|
+
sendToTab(ws, JSON.stringify({ jsonrpc: "2.0", id: parsed.id ?? null, result }), "approval-lease");
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, error.message), "approval-lease");
|
|
647
|
+
}
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const hostReply = replyToHostSessionQuery(parsed, sessionManager, workspaceCwd, isRunning, hubAuth ? runReplay : undefined);
|
|
257
651
|
if (hostReply) {
|
|
258
|
-
ws
|
|
652
|
+
sendToTab(ws, hostReply, "host-session-query");
|
|
259
653
|
return;
|
|
260
654
|
}
|
|
261
|
-
const policyReply = authorizeServeRequest(parsed, sessionManager, workspaceCwd);
|
|
655
|
+
const policyReply = authorizeServeRequest(parsed, sessionManager, workspaceCwd, isRunning);
|
|
262
656
|
if (policyReply) {
|
|
263
|
-
ws
|
|
657
|
+
sendToTab(ws, policyReply, "request-policy");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (hubAuth &&
|
|
661
|
+
(parsed.id === undefined ||
|
|
662
|
+
parsed.id === null ||
|
|
663
|
+
(typeof parsed.id !== "string" && typeof parsed.id !== "number"))) {
|
|
664
|
+
sendToTab(ws, hostQueryError(null, -32600, "request id required"), "request-id");
|
|
264
665
|
return;
|
|
265
666
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
// not silently become the worker's global no-repo conversation.
|
|
269
|
-
const workerMessage = parsed.method === "agent/run"
|
|
270
|
-
? { ...parsed, params: { ...(parsed.params ?? {}), cwd: workspaceCwd } }
|
|
271
|
-
: parsed;
|
|
272
|
-
if (workerMessage.id === null) {
|
|
273
|
-
ws.send(hostQueryError(null, -32600, "JSON-RPC request id must not be null"));
|
|
667
|
+
if ((pendingResponsesByTab.get(tabId) ?? 0) >= 64) {
|
|
668
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32000, "too many pending agent worker requests for this tab"), "pending-limit");
|
|
274
669
|
return;
|
|
275
670
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
671
|
+
let approvalId;
|
|
672
|
+
if (hubAuth && parsed.method === "agent/approve") {
|
|
673
|
+
const requestId = parsed.params?.requestId;
|
|
674
|
+
const approval = typeof requestId === "string" ? leases.get(requestId) : undefined;
|
|
675
|
+
if (!approval || approval.sessionId !== parsed.params?.sessionId) {
|
|
676
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, "approval is no longer pending"), "approval");
|
|
279
677
|
return;
|
|
280
678
|
}
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
|
|
679
|
+
const decision = parsed.params?.decision;
|
|
680
|
+
if (!decision ||
|
|
681
|
+
typeof decision !== "object" ||
|
|
682
|
+
typeof decision.approved !== "boolean") {
|
|
683
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, "approval decision required"), "approval");
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
try {
|
|
687
|
+
leases.submit(approval.requestId, holderId);
|
|
688
|
+
approvalId = approval.requestId;
|
|
689
|
+
// Connection/generation belong to the worker's request, never to browser input.
|
|
690
|
+
parsed.params = {
|
|
691
|
+
sessionId: approval.sessionId,
|
|
692
|
+
requestId: approval.requestId,
|
|
693
|
+
decision,
|
|
694
|
+
...(approval.connectionId !== undefined
|
|
695
|
+
? { connectionId: approval.connectionId }
|
|
696
|
+
: {}),
|
|
697
|
+
...(approval.generation !== undefined ? { generation: approval.generation } : {}),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, error.message), "approval");
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
let preparedUploads;
|
|
706
|
+
let uploadsTransferred = false;
|
|
707
|
+
const preparingRun = !!hubAuth && parsed.method === "agent/run";
|
|
708
|
+
if (hubAuth && (configurationChanging || configurationReloadFailed)) {
|
|
709
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32009, configurationReloadFailed
|
|
710
|
+
? "请先重新保存配置或重启服务,再发送任务。"
|
|
711
|
+
: "配置正在保存,请稍后重新发送任务。"), "configuration-changing");
|
|
712
|
+
return;
|
|
290
713
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
714
|
+
if (preparingRun)
|
|
715
|
+
preparingRuns++;
|
|
716
|
+
try {
|
|
717
|
+
if (hubAuth && parsed.method === "agent/run") {
|
|
718
|
+
// Server-assigned ids ensure even clients that omit them have
|
|
719
|
+
// tracked runs, upload ownership and refreshable stream snapshots.
|
|
720
|
+
parsed.params = {
|
|
721
|
+
...parsed.params,
|
|
722
|
+
sessionId: parsed.params?.sessionId ?? randomUUID(),
|
|
723
|
+
clientMessageId: parsed.params?.clientMessageId ?? randomUUID(),
|
|
724
|
+
};
|
|
725
|
+
if (runningSessions.size >= 64) {
|
|
726
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32000, "too many active runs"), "run-limit");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
preparedUploads = await uploads.prepare(parsed.params?.uploadIds, session.id, parsed.params?.sessionId);
|
|
731
|
+
const { uploadIds: _uploadIds, attachments: _attachments, ...params } = parsed.params ?? {};
|
|
732
|
+
const attachments = preparedUploads.attachments;
|
|
733
|
+
parsed.params = { ...params, ...(attachments.length ? { attachments } : {}) };
|
|
734
|
+
}
|
|
735
|
+
catch (error) {
|
|
736
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, error.message), "attachments");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (ws.readyState !== ws.OPEN || !(await hubAuth.authenticate(req)))
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// Recheck after asynchronous staging/authentication; other tabs may
|
|
743
|
+
// have filled the runtime's capacity while this request was suspended.
|
|
744
|
+
if (hubAuth && parsed.method === "agent/run" && runningSessions.size >= 64) {
|
|
745
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32000, "too many active runs"), "run-limit");
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
// This host intentionally exposes one workspace. A browser must not be
|
|
749
|
+
// able to escape it by supplying another cwd, and a missing cwd must
|
|
750
|
+
// not silently become the worker's global no-repo conversation.
|
|
751
|
+
let defaultModel;
|
|
752
|
+
if (hubAuth && parsed.method === "agent/run" && parsed.params?.model === undefined) {
|
|
753
|
+
try {
|
|
754
|
+
// The preparation guard remains held while reading and dispatching.
|
|
755
|
+
// Reloading the shared model pool alone does not change an existing
|
|
756
|
+
// session's selected model; this normal run parameter switches it at
|
|
757
|
+
// the turn boundary and also refreshes edited connection credentials.
|
|
758
|
+
defaultModel = new SettingsManager(workspaceCwd, "full").get().defaults.text;
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, "模型设置读取失败,请检查服务器配置。"), "configuration-invalid");
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const workerMessage = parsed.method === "agent/run"
|
|
766
|
+
? {
|
|
767
|
+
...parsed,
|
|
768
|
+
params: {
|
|
769
|
+
...(parsed.params ?? {}),
|
|
770
|
+
...(defaultModel ? { model: defaultModel } : {}),
|
|
771
|
+
cwd: workspaceCwd,
|
|
772
|
+
},
|
|
773
|
+
}
|
|
774
|
+
: parsed;
|
|
775
|
+
if (workerMessage.id === null) {
|
|
776
|
+
sendToTab(ws, hostQueryError(null, -32600, "JSON-RPC request id must not be null"), "request-id");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
let baselineEvents = 0;
|
|
780
|
+
const baselineSessionId = workerMessage.params?.sessionId;
|
|
781
|
+
if (hubAuth && parsed.method === "agent/run" && typeof baselineSessionId === "string") {
|
|
782
|
+
try {
|
|
783
|
+
if (readOptionalHubSessionState(sessionManager, baselineSessionId)) {
|
|
784
|
+
baselineEvents = readHubTranscript(sessionRootDir, baselineSessionId).length;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
catch (cause) {
|
|
788
|
+
sendToTab(ws, hostQueryError(workerMessage.id ?? null, -32001, cause instanceof Error ? cause.message : "会话记录无法读取,请检查服务器备份。"), "transcript-unavailable");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (workerMessage.id !== undefined) {
|
|
793
|
+
if ((pendingResponsesByTab.get(tabId) ?? 0) >= 64) {
|
|
794
|
+
sendToTab(ws, hostQueryError(workerMessage.id, -32000, "too many pending agent worker requests for this tab"), "pending-limit");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (hubAuth && parsed.method === "agent/run") {
|
|
798
|
+
const runSession = workerMessage.params?.sessionId;
|
|
799
|
+
const ownerId = tabAuth.get(ws)?.sessionId;
|
|
800
|
+
const activeOwner = typeof runSession === "string" ? runOwners.get(runSession) : undefined;
|
|
801
|
+
if (activeOwner && activeOwner !== ownerId) {
|
|
802
|
+
sendToTab(ws, hostQueryError(workerMessage.id, -32009, "此对话正在另一台设备运行,请等待完成后再发送。"), "run-owner");
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const workerRequestId = `serve-${tabId}-${nextWorkerRequestId++}`;
|
|
807
|
+
pendingWorkerResponses.set(workerRequestId, {
|
|
808
|
+
tab: ws,
|
|
809
|
+
originalId: workerMessage.id,
|
|
810
|
+
tabId,
|
|
811
|
+
insertedAt: Date.now(),
|
|
812
|
+
method: parsed.method,
|
|
813
|
+
...(approvalId ? { approvalId } : {}),
|
|
814
|
+
});
|
|
815
|
+
pendingResponsesByTab.set(tabId, (pendingResponsesByTab.get(tabId) ?? 0) + 1);
|
|
816
|
+
workerMessage.id = workerRequestId;
|
|
817
|
+
if (preparedUploads?.attachments.length) {
|
|
818
|
+
runUploads.set(workerRequestId, preparedUploads);
|
|
819
|
+
uploadsTransferred = true;
|
|
820
|
+
}
|
|
821
|
+
const runSessionId = workerMessage.params?.sessionId;
|
|
822
|
+
if (parsed.method === "agent/run" && typeof runSessionId === "string") {
|
|
823
|
+
if (hubAuth) {
|
|
824
|
+
runReplay.begin(runSessionId, workerRequestId, baselineEvents);
|
|
825
|
+
const params = workerMessage.params ?? {};
|
|
826
|
+
runInputs.set(workerRequestId, {
|
|
827
|
+
sessionId: runSessionId,
|
|
828
|
+
echoed: false,
|
|
829
|
+
event: {
|
|
830
|
+
type: "session_user_message",
|
|
831
|
+
text: typeof params.displayText === "string" ? params.displayText : params.task,
|
|
832
|
+
clientMessageId: params.clientMessageId,
|
|
833
|
+
attachments: (preparedUploads?.attachments ?? []).map((attachment) => ({
|
|
834
|
+
name: attachment.originalName ?? basename(attachment.path),
|
|
835
|
+
size: attachment.size,
|
|
836
|
+
mime: attachment.mime,
|
|
837
|
+
path: attachment.relPath ?? attachment.absPath,
|
|
838
|
+
})),
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
runningSessions.set(workerRequestId, runSessionId);
|
|
843
|
+
const ownerId = tabAuth.get(ws)?.sessionId;
|
|
844
|
+
if (ownerId)
|
|
845
|
+
runOwners.set(runSessionId, ownerId);
|
|
846
|
+
if (hubAuth)
|
|
847
|
+
notify("serve/sessionStatus", { sessionId: runSessionId, running: true });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
const workerLine = JSON.stringify(workerMessage);
|
|
851
|
+
// Spawn-on-first-frame (idempotent): the browser's first request wakes
|
|
852
|
+
// the worker, mirroring the renderer's spawn-on-agent/run semantics.
|
|
853
|
+
bridge.ensureWorker(opts.cwd);
|
|
854
|
+
if (!bridge.canSend()) {
|
|
855
|
+
workerUnavailable("agent worker is unavailable");
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
bridge.injectWorkerMessage(workerLine, { origin: "serve", producer: "serve-ws" });
|
|
859
|
+
}
|
|
860
|
+
finally {
|
|
861
|
+
if (preparingRun)
|
|
862
|
+
preparingRuns--;
|
|
863
|
+
if (!uploadsTransferred)
|
|
864
|
+
preparedUploads?.release();
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
let queued = 0;
|
|
868
|
+
let chain = Promise.resolve();
|
|
869
|
+
ws.on("message", (data) => {
|
|
870
|
+
if (++queued > 64) {
|
|
871
|
+
ws.close(1008, "too many queued requests");
|
|
872
|
+
queued--;
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
chain = chain
|
|
876
|
+
.then(() => handleMessage(data))
|
|
877
|
+
.catch(() => {
|
|
878
|
+
sendToTab(ws, hostQueryError(null, -32000, "request failed"), "request-error");
|
|
879
|
+
})
|
|
880
|
+
.finally(() => {
|
|
881
|
+
queued--;
|
|
882
|
+
});
|
|
296
883
|
});
|
|
297
884
|
ws.on("close", () => {
|
|
298
885
|
tabs.delete(ws);
|
|
886
|
+
tabAuth.delete(ws);
|
|
887
|
+
leases.releaseHolder(holderId);
|
|
299
888
|
clearPendingWorkerResponsesForTab(tabId);
|
|
300
889
|
log("tab.closed", { tabs: tabs.size });
|
|
301
890
|
});
|
|
302
891
|
ws.on("error", () => {
|
|
303
892
|
tabs.delete(ws);
|
|
893
|
+
tabAuth.delete(ws);
|
|
894
|
+
leases.releaseHolder(holderId);
|
|
304
895
|
clearPendingWorkerResponsesForTab(tabId);
|
|
305
896
|
});
|
|
306
897
|
});
|
|
898
|
+
};
|
|
899
|
+
server.on("upgrade", (req, socket, head) => {
|
|
900
|
+
void upgrade(req, socket, head).catch(() => socket.destroy());
|
|
307
901
|
});
|
|
308
902
|
try {
|
|
309
903
|
await new Promise((resolve, reject) => {
|
|
@@ -316,6 +910,15 @@ export async function startHeadlessServer(opts) {
|
|
|
316
910
|
}
|
|
317
911
|
catch (error) {
|
|
318
912
|
clearInterval(pendingResponseReaper);
|
|
913
|
+
if (authReaper)
|
|
914
|
+
clearInterval(authReaper);
|
|
915
|
+
await uploads?.close();
|
|
916
|
+
await skills?.close();
|
|
917
|
+
await mcp?.close();
|
|
918
|
+
configuration?.close();
|
|
919
|
+
links?.close();
|
|
920
|
+
panels?.close();
|
|
921
|
+
files?.close();
|
|
319
922
|
bridge.kill();
|
|
320
923
|
wss.close();
|
|
321
924
|
server.close();
|
|
@@ -330,12 +933,22 @@ export async function startHeadlessServer(opts) {
|
|
|
330
933
|
host,
|
|
331
934
|
port,
|
|
332
935
|
...(generatedPasscode ? { generatedPasscode } : {}),
|
|
936
|
+
...(hubAuth?.bootstrapToken ? { bootstrapToken: hubAuth.bootstrapToken } : {}),
|
|
333
937
|
passcode,
|
|
334
938
|
bridge,
|
|
335
939
|
tabCount: () => tabs.size,
|
|
336
940
|
pendingResponseCount: () => pendingWorkerResponses.size,
|
|
337
941
|
close: async () => {
|
|
338
942
|
clearInterval(pendingResponseReaper);
|
|
943
|
+
if (authReaper)
|
|
944
|
+
clearInterval(authReaper);
|
|
945
|
+
await uploads?.close();
|
|
946
|
+
await skills?.close();
|
|
947
|
+
await mcp?.close();
|
|
948
|
+
configuration?.close();
|
|
949
|
+
links?.close();
|
|
950
|
+
panels?.close();
|
|
951
|
+
files?.close();
|
|
339
952
|
for (const tab of tabs) {
|
|
340
953
|
try {
|
|
341
954
|
tab.terminate();
|
|
@@ -369,14 +982,13 @@ function webSocketPayloadBytes(data) {
|
|
|
369
982
|
}
|
|
370
983
|
return Buffer.byteLength(String(data), "utf8");
|
|
371
984
|
}
|
|
372
|
-
function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
|
|
985
|
+
function replyToHostSessionQuery(message, sessionManager, workspaceCwd, isRunning = () => false, runReplay) {
|
|
373
986
|
if (message.method !== "agent/query" || message.id === undefined)
|
|
374
987
|
return undefined;
|
|
375
988
|
const queryType = message.params?.type;
|
|
376
989
|
if (queryType === "sessions") {
|
|
377
990
|
const sessions = sessionManager
|
|
378
|
-
.list()
|
|
379
|
-
.filter((session) => resolve(session.cwd) === workspaceCwd)
|
|
991
|
+
.list(100, { cwd: workspaceCwd, archived: false, rootsOnly: true })
|
|
380
992
|
.map((session) => ({
|
|
381
993
|
sessionId: session.sessionId,
|
|
382
994
|
cwd: session.cwd,
|
|
@@ -384,7 +996,9 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
|
|
|
384
996
|
model: session.model,
|
|
385
997
|
status: session.status,
|
|
386
998
|
turnCount: session.turnCount,
|
|
387
|
-
|
|
999
|
+
lastActiveAt: session.lastActiveAt,
|
|
1000
|
+
...(session.title ? { title: session.title } : {}),
|
|
1001
|
+
...(session.preview ? { preview: hubSessionPreview(session.preview) } : {}),
|
|
388
1002
|
}));
|
|
389
1003
|
return JSON.stringify({
|
|
390
1004
|
jsonrpc: "2.0",
|
|
@@ -398,31 +1012,68 @@ function replyToHostSessionQuery(message, sessionManager, workspaceCwd) {
|
|
|
398
1012
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
399
1013
|
return hostQueryError(message.id, -32602, "sessionId required for session_detail");
|
|
400
1014
|
}
|
|
1015
|
+
let missingSession = false;
|
|
401
1016
|
try {
|
|
402
|
-
const
|
|
403
|
-
|
|
1017
|
+
const state = readOptionalHubSessionState(sessionManager, sessionId);
|
|
1018
|
+
missingSession = !state;
|
|
1019
|
+
if (!state)
|
|
1020
|
+
throw new Error("Session not found in this workspace");
|
|
1021
|
+
if (resolve(state.cwd) !== workspaceCwd) {
|
|
404
1022
|
return hostQueryError(message.id, -32001, "Session not found in this workspace");
|
|
405
1023
|
}
|
|
1024
|
+
const transcript = readHubTranscript(sessionManager.getStorageDir(), sessionId);
|
|
406
1025
|
return JSON.stringify({
|
|
407
1026
|
jsonrpc: "2.0",
|
|
408
1027
|
id: message.id,
|
|
409
1028
|
result: {
|
|
410
1029
|
type: "session_detail",
|
|
411
|
-
data: {
|
|
1030
|
+
data: {
|
|
1031
|
+
state,
|
|
1032
|
+
...(runReplay ? runReplay.snapshot(sessionId, transcript) : { transcript }),
|
|
1033
|
+
running: isRunning(sessionId),
|
|
1034
|
+
},
|
|
412
1035
|
},
|
|
413
1036
|
});
|
|
414
1037
|
}
|
|
415
1038
|
catch (error) {
|
|
1039
|
+
if (runReplay?.hasEmptyBaseline(sessionId) &&
|
|
1040
|
+
isRunning(sessionId) &&
|
|
1041
|
+
(missingSession || error.status === 404)) {
|
|
1042
|
+
return JSON.stringify({
|
|
1043
|
+
jsonrpc: "2.0",
|
|
1044
|
+
id: message.id,
|
|
1045
|
+
result: {
|
|
1046
|
+
type: "session_detail",
|
|
1047
|
+
data: {
|
|
1048
|
+
state: { sessionId, cwd: workspaceCwd, status: "active", turnCount: 0 },
|
|
1049
|
+
...runReplay.snapshot(sessionId, []),
|
|
1050
|
+
running: true,
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
416
1055
|
return hostQueryError(message.id, -32001, error instanceof Error ? error.message : "Session not found");
|
|
417
1056
|
}
|
|
418
1057
|
}
|
|
1058
|
+
/** A missing session can be created; malformed or unsafe existing storage must fail closed. */
|
|
1059
|
+
function readOptionalHubSessionState(sessionManager, sessionId) {
|
|
1060
|
+
try {
|
|
1061
|
+
return readHubSessionState(sessionManager.getStorageDir(), sessionId);
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
if (error.status === 404 && !sessionManager.exists(sessionId)) {
|
|
1065
|
+
return undefined;
|
|
1066
|
+
}
|
|
1067
|
+
throw error;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
419
1070
|
const SERVE_ALLOWED_WORKER_METHODS = new Set(["agent/run", "agent/approve", "agent/cancel"]);
|
|
420
1071
|
/**
|
|
421
1072
|
* The no-account Web host deliberately exposes only the methods used by its
|
|
422
1073
|
* bundled SPA. Passcode possession grants control of this workspace, not raw
|
|
423
1074
|
* access to the worker's full local protocol surface.
|
|
424
1075
|
*/
|
|
425
|
-
function authorizeServeRequest(message, sessionManager, workspaceCwd) {
|
|
1076
|
+
function authorizeServeRequest(message, sessionManager, workspaceCwd, isRunning = () => false) {
|
|
426
1077
|
if (!message.method || !SERVE_ALLOWED_WORKER_METHODS.has(message.method)) {
|
|
427
1078
|
return hostQueryError(message.id ?? null, -32601, "Method is not available in Web serve mode");
|
|
428
1079
|
}
|
|
@@ -433,16 +1084,19 @@ function authorizeServeRequest(message, sessionManager, workspaceCwd) {
|
|
|
433
1084
|
if (!sessionId) {
|
|
434
1085
|
return hostQueryError(message.id ?? null, -32602, "sessionId is required");
|
|
435
1086
|
}
|
|
1087
|
+
// A tracked run was already authorized for this host's forced workspace.
|
|
1088
|
+
// Stop must work even before Core publishes its first persistent state file.
|
|
1089
|
+
if (message.method === "agent/cancel" && isRunning(sessionId))
|
|
1090
|
+
return undefined;
|
|
436
1091
|
try {
|
|
437
|
-
const session = sessionManager
|
|
438
|
-
if (resolve(session.cwd) === workspaceCwd)
|
|
1092
|
+
const session = readOptionalHubSessionState(sessionManager, sessionId);
|
|
1093
|
+
if (session && resolve(session.cwd) === workspaceCwd)
|
|
1094
|
+
return undefined;
|
|
1095
|
+
if (!session && !sessionManager.exists(sessionId) && message.method === "agent/run")
|
|
439
1096
|
return undefined;
|
|
440
1097
|
}
|
|
441
1098
|
catch {
|
|
442
|
-
//
|
|
443
|
-
// an already-persisted session owned by this workspace.
|
|
444
|
-
if (message.method === "agent/run")
|
|
445
|
-
return undefined;
|
|
1099
|
+
// Invalid ids and unreadable existing records must not become new sessions.
|
|
446
1100
|
}
|
|
447
1101
|
return hostQueryError(message.id ?? null, -32001, "Session not found in this workspace");
|
|
448
1102
|
}
|