@cjhyy/code-shell-server 0.9.47 → 0.9.48
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 +4 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +26 -7
- package/dist/mobile-remote/codex-room-agent.js +3 -1
- package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
- package/dist/mobile-remote/mobile-chat-turn.js +15 -2
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
- package/dist/mobile-remote/mobile-static.d.ts +5 -6
- package/dist/mobile-remote/mobile-static.js +13 -14
- package/dist/mobile-remote/resident-agent.js +3 -1
- package/dist/mobile-remote/room-manager.d.ts +4 -0
- package/dist/mobile-remote/room-manager.js +5 -0
- package/dist/mobile-remote/room-process-stop.d.ts +8 -0
- package/dist/mobile-remote/room-process-stop.js +18 -0
- package/dist/panels/execution-gate.d.ts +3 -1
- package/dist/panels/execution-gate.js +10 -2
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/management-http.d.ts +1 -0
- package/dist/panels/management.d.ts +3 -0
- package/dist/panels/management.js +63 -15
- package/dist/panels/package-mutation.d.ts +1 -1
- package/dist/panels/package-mutation.js +3 -10
- package/dist/serve/headless-server.js +113 -79
- package/dist/static-files.d.ts +7 -0
- package/dist/static-files.js +114 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -83,6 +83,10 @@ Desktop Web 和 Hub 在注销或显式撤销会话时,会关闭该会话尚未
|
|
|
83
83
|
响应,包括正在下载的文件,并停止源文件读取、释放句柄。其他会话的下载不受影响;
|
|
84
84
|
已经返回接受结果的后台任务仍由原来的任务生命周期管理。注销请求本身仍返回成功。
|
|
85
85
|
|
|
86
|
+
Hub 认证每次读取磁盘上的当前会话;只有需要更新活动时间时才获取写锁,并在锁内
|
|
87
|
+
重新验证撤销与过期状态。Hub 与 Mobile 静态文件按打开时的大小流式发送,`HEAD`
|
|
88
|
+
只返回元数据;客户端断开会停止读取。首页和 SPA 回退也遵守静态目录边界。
|
|
89
|
+
|
|
86
90
|
## code-shell-serve — 个人 Hub Web host
|
|
87
91
|
|
|
88
92
|
在任意机器上把一个 workspace 变成浏览器可访问的 CodeShell:
|
package/dist/hub/auth-store.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare class HubAuthStore {
|
|
|
40
40
|
}): Promise<HubSessionGrant>;
|
|
41
41
|
login(input: HubLoginInput, replaceToken?: string): Promise<HubSessionGrant>;
|
|
42
42
|
authenticate(token: string | undefined): HubSession | null;
|
|
43
|
+
private findSession;
|
|
43
44
|
listSessions(): HubSession[];
|
|
44
45
|
/** Offline restore must not resurrect sessions revoked after the snapshot. */
|
|
45
46
|
revokeAllSessions(): void;
|
|
@@ -49,6 +50,7 @@ export declare class HubAuthStore {
|
|
|
49
50
|
private toPublic;
|
|
50
51
|
private mutate;
|
|
51
52
|
private ensureDirectory;
|
|
53
|
+
private assertDirectory;
|
|
52
54
|
private read;
|
|
53
55
|
private write;
|
|
54
56
|
}
|
package/dist/hub/auth-store.js
CHANGED
|
@@ -93,20 +93,36 @@ export class HubAuthStore {
|
|
|
93
93
|
authenticate(token) {
|
|
94
94
|
if (!token || !TOKEN_PATTERN.test(token))
|
|
95
95
|
return null;
|
|
96
|
+
// Writers publish by atomic rename. Read the committed record on every
|
|
97
|
+
// request, but only contend for the writer lock when activity needs saving.
|
|
98
|
+
this.assertDirectory();
|
|
99
|
+
const tokenHash = hashToken(token);
|
|
100
|
+
const record = this.read();
|
|
101
|
+
const now = this.now();
|
|
102
|
+
const session = this.findSession(record, tokenHash, now);
|
|
103
|
+
if (!session)
|
|
104
|
+
return null;
|
|
105
|
+
if (now - session.lastSeenAt < 60_000)
|
|
106
|
+
return this.toPublic(record, session);
|
|
96
107
|
return this.mutate((record) => {
|
|
97
108
|
const now = this.now();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
109
|
+
// Recheck after taking the lock: a peer may have revoked, replaced or
|
|
110
|
+
// refreshed the session since the unlocked read. Never write that snapshot.
|
|
111
|
+
const session = this.findSession(record, tokenHash, now);
|
|
112
|
+
if (!session)
|
|
101
113
|
return { result: null, changed: false };
|
|
102
|
-
}
|
|
103
|
-
// Persist activity at most once per minute; validation always reads disk.
|
|
104
114
|
const changed = now - session.lastSeenAt >= 60_000;
|
|
105
115
|
if (changed)
|
|
106
116
|
session.lastSeenAt = now;
|
|
107
117
|
return { result: this.toPublic(record, session), changed };
|
|
108
118
|
});
|
|
109
119
|
}
|
|
120
|
+
findSession(record, tokenHash, now) {
|
|
121
|
+
if (!record.account)
|
|
122
|
+
return null;
|
|
123
|
+
const session = record.sessions.find((row) => safeHashEqual(row.tokenHash, tokenHash));
|
|
124
|
+
return session && session.expiresAt > now && session.createdAt <= now ? session : null;
|
|
125
|
+
}
|
|
110
126
|
listSessions() {
|
|
111
127
|
const record = this.read();
|
|
112
128
|
const now = this.now();
|
|
@@ -194,10 +210,13 @@ export class HubAuthStore {
|
|
|
194
210
|
ensureDirectory() {
|
|
195
211
|
const parent = dirname(this.filePath);
|
|
196
212
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
197
|
-
|
|
213
|
+
this.assertDirectory();
|
|
214
|
+
chmodSync(parent, 0o700);
|
|
215
|
+
}
|
|
216
|
+
assertDirectory() {
|
|
217
|
+
const metadata = lstatSync(dirname(this.filePath));
|
|
198
218
|
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
199
219
|
throw new Error("Invalid Hub auth directory");
|
|
200
|
-
chmodSync(parent, 0o700);
|
|
201
220
|
}
|
|
202
221
|
read(allowMissing = false) {
|
|
203
222
|
let descriptor;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { stopRoomProcess } from "./room-process-stop.js";
|
|
2
|
+
import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
5
5
|
import { parseCodexJsonLine, extractThreadId } from "./codex-parse.js";
|
|
@@ -109,6 +109,8 @@ export class CodexRoomAgent {
|
|
|
109
109
|
stdio: ["pipe", "pipe", "pipe"],
|
|
110
110
|
});
|
|
111
111
|
this.child = child;
|
|
112
|
+
// Settle on `close` so trailing output is read, without waiting on grandchildren.
|
|
113
|
+
releaseStdioAfterExit(child);
|
|
112
114
|
if (child.stdout) {
|
|
113
115
|
const rl = createInterface({ input: child.stdout });
|
|
114
116
|
rl.on("line", (line) => {
|
|
@@ -33,6 +33,12 @@ export type DispatchMobileChatTurnResult = {
|
|
|
33
33
|
} | {
|
|
34
34
|
ok: false;
|
|
35
35
|
message: string;
|
|
36
|
+
uncertain?: false;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
message: string;
|
|
40
|
+
uncertain: true;
|
|
41
|
+
clientMessageId: string;
|
|
36
42
|
};
|
|
37
43
|
/** Transaction boundary for mobile chat: workspace → canonical stage → worker ack → finalize. */
|
|
38
44
|
export declare function dispatchMobileChatTurn(input: DispatchMobileChatTurnInput): Promise<DispatchMobileChatTurnResult>;
|
|
@@ -63,11 +63,24 @@ export async function dispatchMobileChatTurn(input) {
|
|
|
63
63
|
: await injectMobileRunAndAwaitAcceptance(input.bridge, request, input.meta);
|
|
64
64
|
}
|
|
65
65
|
catch {
|
|
66
|
-
acceptance = {
|
|
66
|
+
acceptance = {
|
|
67
|
+
ok: false,
|
|
68
|
+
uncertain: true,
|
|
69
|
+
message: "The selected runtime did not confirm the submission",
|
|
70
|
+
};
|
|
67
71
|
}
|
|
68
72
|
if (!acceptance.ok) {
|
|
73
|
+
// Worker input refers to the canonical staged files, not these temporary
|
|
74
|
+
// upload spools. Keep the staged files for a possibly accepted run and
|
|
75
|
+
// release only the claims so the same intent can retry its attachments.
|
|
76
|
+
// A late acceptance may never be observed, so uncertain files also need
|
|
77
|
+
// sent retention rather than expiring as abandoned drafts after one day.
|
|
78
|
+
if (acceptance.uncertain)
|
|
79
|
+
await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
|
|
69
80
|
await settleClaims(input.uploads, input.deviceId, materialized.claims, "release");
|
|
70
|
-
return
|
|
81
|
+
return acceptance.uncertain
|
|
82
|
+
? { ok: false, message: acceptance.message, uncertain: true, clientMessageId }
|
|
83
|
+
: { ok: false, message: acceptance.message };
|
|
71
84
|
}
|
|
72
85
|
await (input.markSent ?? markAttachmentsSent)(cwd, input.sessionId, materialized.metas).catch(() => undefined);
|
|
73
86
|
await settleClaims(input.uploads, input.deviceId, materialized.claims, "finalize");
|
|
@@ -13,6 +13,7 @@ export type MobileRunAcceptance = {
|
|
|
13
13
|
ok: false;
|
|
14
14
|
message: string;
|
|
15
15
|
code?: number;
|
|
16
|
+
uncertain?: true;
|
|
16
17
|
};
|
|
17
18
|
/** Inject agent/run and wait for its request-correlated validation/queue acknowledgement. */
|
|
18
19
|
export declare function injectMobileRunAndAwaitAcceptance(bridge: MobileRunBridge, request: MobileRunRequest, meta: WorkerFrameMeta, timeoutMs?: number): Promise<MobileRunAcceptance>;
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeoutMs = 5_000) {
|
|
3
3
|
return new Promise((resolve) => {
|
|
4
4
|
let settled = false;
|
|
5
|
-
let timer;
|
|
6
5
|
const finish = (result) => {
|
|
7
6
|
if (settled)
|
|
8
7
|
return;
|
|
@@ -40,17 +39,26 @@ export function injectMobileRunAndAwaitAcceptance(bridge, request, meta, timeout
|
|
|
40
39
|
// Not JSON or not part of this request.
|
|
41
40
|
}
|
|
42
41
|
});
|
|
43
|
-
timer = setTimeout(() => finish({ ok: false, message: "worker did not acknowledge the run" }), timeoutMs);
|
|
42
|
+
const timer = setTimeout(() => finish({ ok: false, uncertain: true, message: "worker did not acknowledge the run" }), timeoutMs);
|
|
43
|
+
let injecting = false;
|
|
44
44
|
try {
|
|
45
|
-
|
|
45
|
+
const line = JSON.stringify({
|
|
46
46
|
jsonrpc: "2.0",
|
|
47
47
|
id: request.id,
|
|
48
48
|
method: "agent/run",
|
|
49
49
|
params: request.params,
|
|
50
|
-
})
|
|
50
|
+
});
|
|
51
|
+
injecting = true;
|
|
52
|
+
bridge.injectWorkerMessage(line, meta);
|
|
51
53
|
}
|
|
52
54
|
catch (error) {
|
|
53
|
-
|
|
55
|
+
// An injection can fail after queuing bytes. Only serialization failure
|
|
56
|
+
// proves that this request never reached the transport.
|
|
57
|
+
finish({
|
|
58
|
+
ok: false,
|
|
59
|
+
message: error instanceof Error ? error.message : String(error),
|
|
60
|
+
...(injecting ? { uncertain: true } : {}),
|
|
61
|
+
});
|
|
54
62
|
}
|
|
55
63
|
});
|
|
56
64
|
}
|
|
@@ -38,12 +38,11 @@ export declare function mobileEntryRedirect(reqUrl: string): string | null;
|
|
|
38
38
|
* can see through it. Guarding that would need inode/device policy, and anyone
|
|
39
39
|
* able to hardlink into the served root can already write files there.
|
|
40
40
|
*
|
|
41
|
-
* The returned path is the REQUESTED one, not the realpath:
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* which is out of scope for this fix. */
|
|
41
|
+
* The returned path is the REQUESTED one, not the realpath: the resolved-name
|
|
42
|
+
* contract is relied on elsewhere (on macOS the realpath of a /var root is
|
|
43
|
+
* /private/var, which would change every result). This is only a routing check;
|
|
44
|
+
* both HTTP callers open and revalidate the selected file through
|
|
45
|
+
* streamStaticFile before returning bytes. */
|
|
47
46
|
export declare function resolveSafe(root: string, subPath: string): string | null;
|
|
48
47
|
export interface MobileStaticOptions {
|
|
49
48
|
/** Absolute path to the built mobile app (out/mobile). */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join, normalize, relative, resolve, sep, extname } from "node:path";
|
|
3
3
|
import { request as httpRequest } from "node:http";
|
|
4
|
-
import { contentTypeFor } from "../static-files.js";
|
|
4
|
+
import { contentTypeFor, streamStaticFile } from "../static-files.js";
|
|
5
5
|
/**
|
|
6
6
|
* Serves the built mobile web app (out/mobile) as static assets for the
|
|
7
7
|
* `/mobile` route family, replacing the old inline `mobileRemoteHtml()` string.
|
|
@@ -61,12 +61,11 @@ function isContained(root, target) {
|
|
|
61
61
|
* can see through it. Guarding that would need inode/device policy, and anyone
|
|
62
62
|
* able to hardlink into the served root can already write files there.
|
|
63
63
|
*
|
|
64
|
-
* The returned path is the REQUESTED one, not the realpath:
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* which is out of scope for this fix. */
|
|
64
|
+
* The returned path is the REQUESTED one, not the realpath: the resolved-name
|
|
65
|
+
* contract is relied on elsewhere (on macOS the realpath of a /var root is
|
|
66
|
+
* /private/var, which would change every result). This is only a routing check;
|
|
67
|
+
* both HTTP callers open and revalidate the selected file through
|
|
68
|
+
* streamStaticFile before returning bytes. */
|
|
70
69
|
export function resolveSafe(root, subPath) {
|
|
71
70
|
const rootResolved = resolve(root);
|
|
72
71
|
const rel = normalize(subPath || "index.html");
|
|
@@ -77,11 +76,11 @@ export function resolveSafe(root, subPath) {
|
|
|
77
76
|
const full = resolve(join(rootResolved, rel));
|
|
78
77
|
if (!isContained(rootResolved, full))
|
|
79
78
|
return null;
|
|
80
|
-
if (!existsSync(full) || !statSync(full).isFile())
|
|
81
|
-
return null;
|
|
82
79
|
// realpathSync throws on a dangling link or a race-deleted file; a missing
|
|
83
80
|
// target is a 404 through the normal contract, never a 500.
|
|
84
81
|
try {
|
|
82
|
+
if (!existsSync(full) || !statSync(full).isFile())
|
|
83
|
+
return null;
|
|
85
84
|
if (!isContained(realpathSync(rootResolved), realpathSync(full)))
|
|
86
85
|
return null;
|
|
87
86
|
}
|
|
@@ -143,15 +142,15 @@ export function serveMobile(req, res, opts) {
|
|
|
143
142
|
// stale index.html would reference deleted assets → 404 → blank page until a
|
|
144
143
|
// hard refresh.
|
|
145
144
|
const isHtml = type.startsWith("text/html");
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
void streamStaticFile(req, res, {
|
|
146
|
+
rootDir: opts.rootDir,
|
|
147
|
+
filePath: file,
|
|
148
|
+
cacheControl: isHtml
|
|
149
149
|
? "no-store"
|
|
150
150
|
: sub.startsWith("assets/")
|
|
151
151
|
? "public, max-age=31536000, immutable"
|
|
152
152
|
: "no-cache",
|
|
153
153
|
});
|
|
154
|
-
createReadStream(file).pipe(res);
|
|
155
154
|
}
|
|
156
155
|
/** Dev-only: forward the request to the mobile vite dev server. The mobile vite
|
|
157
156
|
* config sets base "/mobile/", so vite serves its HMR client, module graph, and
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { stopRoomProcess } from "./room-process-stop.js";
|
|
2
|
+
import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import { pathWithCommonBins } from "./path-bins.js";
|
|
5
5
|
/**
|
|
@@ -149,6 +149,8 @@ export class ResidentAgentProcess {
|
|
|
149
149
|
stdio: ["pipe", "pipe", "pipe"],
|
|
150
150
|
});
|
|
151
151
|
this.child = child;
|
|
152
|
+
// Settle on `close` so trailing output is read, without waiting on grandchildren.
|
|
153
|
+
releaseStdioAfterExit(child);
|
|
152
154
|
if (child.stdout) {
|
|
153
155
|
const rl = createInterface({ input: child.stdout });
|
|
154
156
|
rl.on("line", (line) => {
|
|
@@ -119,6 +119,10 @@ export interface RoomManagerOptions {
|
|
|
119
119
|
* Returning null means the transcript is absent. The returned identity is
|
|
120
120
|
* checked again here so a stale or mismatched locator fails closed. */
|
|
121
121
|
resolveLinkedSession?: (target: Readonly<LinkedSessionTarget>) => LinkedSessionTarget | null | undefined;
|
|
122
|
+
/** Throws while a background task (DriveAgent) may still be writing this
|
|
123
|
+
* external session. Checked before any resident agent can start on it, so
|
|
124
|
+
* desktop and phone entry points share one ownership gate. */
|
|
125
|
+
assertExternalSessionAvailable?: (externalSessionId: string, kind: RoomKind) => void;
|
|
122
126
|
/** Called whenever a room gains a new persisted message (push to phone). */
|
|
123
127
|
onMessage: (roomId: string, msg: RoomMessage) => void;
|
|
124
128
|
/** Called when a room's resident agent requests tool-use approval. For
|
|
@@ -777,6 +777,8 @@ export class RoomManager {
|
|
|
777
777
|
* so a given claude session maps to exactly one room. Returns the room id.
|
|
778
778
|
*/
|
|
779
779
|
openForSession(claudeSessionId, cwd, mode, kind = "claude-code") {
|
|
780
|
+
if (claudeSessionId)
|
|
781
|
+
this.opts.assertExternalSessionAvailable?.(claudeSessionId, kind);
|
|
780
782
|
// Reuse must match BOTH id and kind: a codex thread id and a claude session
|
|
781
783
|
// id live in the same `claudeSessionId` field, so a bare id match could
|
|
782
784
|
// otherwise hand back a claude room when a codex room was asked for.
|
|
@@ -851,6 +853,7 @@ export class RoomManager {
|
|
|
851
853
|
* silently start a different session. */
|
|
852
854
|
takeOverLinkedSession(roomId, externalSessionId, cwd, kind) {
|
|
853
855
|
const target = this.requireLinkedSessionTarget({ externalSessionId, cwd, kind });
|
|
856
|
+
this.opts.assertExternalSessionAvailable?.(target.externalSessionId, target.kind);
|
|
854
857
|
const meta = this.getRoom(roomId);
|
|
855
858
|
if (!meta ||
|
|
856
859
|
meta.claudeSessionId !== target.externalSessionId ||
|
|
@@ -969,6 +972,8 @@ export class RoomManager {
|
|
|
969
972
|
if (meta.linkedSessionMode === "observe-only")
|
|
970
973
|
return { status: "observing" };
|
|
971
974
|
if (!this.agents.has(id)) {
|
|
975
|
+
if (meta.claudeSessionId)
|
|
976
|
+
this.opts.assertExternalSessionAvailable?.(meta.claudeSessionId, meta.kind);
|
|
972
977
|
let agent = undefined;
|
|
973
978
|
agent = this.opts.createAgent(meta, (event) => {
|
|
974
979
|
// stop() sends a signal; an old process can still drain output or emit
|
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
import type { ChildProcess } from "node:child_process";
|
|
2
2
|
/** Retire an unusable room transport without leaving an unresponsive CLI alive. */
|
|
3
3
|
export declare function stopRoomProcess(child: ChildProcess): void;
|
|
4
|
+
/**
|
|
5
|
+
* `close` waits for every holder of the child's stdout/stderr. A grandchild the
|
|
6
|
+
* CLI backgrounded with inherited stdio (a dev server, say) keeps them open
|
|
7
|
+
* after the CLI exits, so the room would never settle. Once the CLI has exited
|
|
8
|
+
* and its buffered output had a grace period to drain, release the pipes;
|
|
9
|
+
* the grandchild itself is left running.
|
|
10
|
+
*/
|
|
11
|
+
export declare function releaseStdioAfterExit(child: ChildProcess, graceMs?: number): void;
|
|
@@ -26,3 +26,21 @@ export function stopRoomProcess(child) {
|
|
|
26
26
|
timer.unref();
|
|
27
27
|
child.once("exit", () => clearTimeout(timer));
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* `close` waits for every holder of the child's stdout/stderr. A grandchild the
|
|
31
|
+
* CLI backgrounded with inherited stdio (a dev server, say) keeps them open
|
|
32
|
+
* after the CLI exits, so the room would never settle. Once the CLI has exited
|
|
33
|
+
* and its buffered output had a grace period to drain, release the pipes;
|
|
34
|
+
* the grandchild itself is left running.
|
|
35
|
+
*/
|
|
36
|
+
export function releaseStdioAfterExit(child, graceMs = 1_000) {
|
|
37
|
+
child.once("exit", () => {
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
child.stdout?.destroy();
|
|
40
|
+
child.stderr?.destroy();
|
|
41
|
+
child.stdin?.destroy();
|
|
42
|
+
}, graceMs);
|
|
43
|
+
timer.unref();
|
|
44
|
+
child.once("close", () => clearTimeout(timer));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -39,6 +39,8 @@ export declare class PanelExecutionGate {
|
|
|
39
39
|
register(source: () => Iterable<PanelExecutionScope>): () => void;
|
|
40
40
|
/** Executions that a change matching `matches` would wait for, grouped by holder. */
|
|
41
41
|
blockers(matches: (scope: PanelExecutionScope) => boolean): PanelExecutionBlocker[];
|
|
42
|
-
mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T
|
|
42
|
+
mutate<T>(matches: (scope: PanelExecutionScope) => boolean, work: () => Promise<T>,
|
|
43
|
+
/** Trusted Host preparation that validates a review before draining existing work. */
|
|
44
|
+
drain?: () => Promise<void>): Promise<T>;
|
|
43
45
|
}
|
|
44
46
|
export declare const panelExecutionGate: PanelExecutionGate;
|
|
@@ -77,15 +77,23 @@ export class PanelExecutionGate {
|
|
|
77
77
|
}
|
|
78
78
|
return [...grouped.values()];
|
|
79
79
|
}
|
|
80
|
-
async mutate(matches, work
|
|
80
|
+
async mutate(matches, work,
|
|
81
|
+
/** Trusted Host preparation that validates a review before draining existing work. */
|
|
82
|
+
drain) {
|
|
81
83
|
if (this.mutations.size)
|
|
82
84
|
throw new PanelExecutionBusyError("另一个面板修改正在进行,请稍后重试。");
|
|
83
85
|
// No await between the snapshot and installing the admission barrier.
|
|
84
86
|
const blockers = this.blockers(matches);
|
|
85
|
-
if (blockers.length)
|
|
87
|
+
if (blockers.length && !drain)
|
|
86
88
|
throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", blockers);
|
|
87
89
|
this.mutations.add(matches);
|
|
88
90
|
try {
|
|
91
|
+
if (drain) {
|
|
92
|
+
await drain();
|
|
93
|
+
const remaining = this.blockers(matches);
|
|
94
|
+
if (remaining.length)
|
|
95
|
+
throw new PanelExecutionBusyError("面板还有正在提交、排队或运行的任务,请结束后再修改。", remaining);
|
|
96
|
+
}
|
|
89
97
|
return await work();
|
|
90
98
|
}
|
|
91
99
|
finally {
|
package/dist/panels/http.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export declare function createPanelHttp(options: PanelManagementHttpOptions & {
|
|
|
39
39
|
install(context: import("./types.js").PanelOperationContext, token: unknown, bind?: boolean, approval?: {
|
|
40
40
|
overwrite?: boolean;
|
|
41
41
|
expectedId?: string;
|
|
42
|
+
releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
|
|
42
43
|
}): Promise<{
|
|
43
44
|
id: string;
|
|
44
45
|
packageDigest: string | undefined;
|
|
@@ -30,6 +30,7 @@ export declare function createPanelManagementHttp(options: PanelManagementHttpOp
|
|
|
30
30
|
install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
|
|
31
31
|
overwrite?: boolean;
|
|
32
32
|
expectedId?: string;
|
|
33
|
+
releaseExecutions?: (appId: string, matches: (scope: import("./execution-gate.js").PanelExecutionScope) => boolean) => Promise<void>;
|
|
33
34
|
}): Promise<{
|
|
34
35
|
id: string;
|
|
35
36
|
packageDigest: string | undefined;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type PanelExecutionScope } from "./execution-gate.js";
|
|
1
2
|
import { type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
|
|
2
3
|
import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelProjectReview, PanelSnapshot, PanelPackageHistory, PanelPackageRestoreReview } from "./types.js";
|
|
3
4
|
export declare class PanelManagementError extends Error {
|
|
@@ -50,6 +51,8 @@ export declare function createPanelManagement(options: PanelManagementOptions):
|
|
|
50
51
|
install(context: PanelOperationContext, token: unknown, bind?: boolean, approval?: {
|
|
51
52
|
overwrite?: boolean;
|
|
52
53
|
expectedId?: string;
|
|
54
|
+
/** Trusted Host callback, never supplied by an HTTP request body. */
|
|
55
|
+
releaseExecutions?: (appId: string, matches: (scope: PanelExecutionScope) => boolean) => Promise<void>;
|
|
53
56
|
}): Promise<{
|
|
54
57
|
id: string;
|
|
55
58
|
packageDigest: string | undefined;
|
|
@@ -3,7 +3,7 @@ import { panelPackageMutationMatches } from "./package-mutation.js";
|
|
|
3
3
|
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { lstatSync, mkdirSync, statSync } from "node:fs";
|
|
5
5
|
import { basename, extname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
-
import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
|
|
6
|
+
import { SettingsManager, PanelAppAlreadyInstalledError, PanelAppInstallError, PanelAppReviewChangedError, assertSafePanelAppId, discoverGitPanelApps, installReviewedLocalPanelApp, invalidateSkillCache, listInstalledPanelApps, inspectProjectPanelApps, listRetainedPanelAppPackages, resolvePanelAppPackage, parsePanelAppPackagePins, currentPanelAppPackagePins, installedPanelAppUpdatedAt, retainInstalledPanelApp, userHome, panelAppsRoot, previewLocalPanelApp, resolveGitHubPanelAppCommit, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
|
|
7
7
|
import { mutateJsonFile } from "@cjhyy/code-shell-core/internal";
|
|
8
8
|
import { parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
|
|
9
9
|
const REVIEW_TTL = 8 * 60_000;
|
|
@@ -177,14 +177,17 @@ export function createPanelManagement(options) {
|
|
|
177
177
|
.update(JSON.stringify({
|
|
178
178
|
app,
|
|
179
179
|
digest,
|
|
180
|
-
...(options.projectPackages
|
|
181
|
-
? { pin: parsePanelAppPackagePins(project ?? projectSettings())[app.id] ?? null }
|
|
182
|
-
: {}),
|
|
180
|
+
...(options.projectPackages ? { pin: selectedPin(app.id, project) } : {}),
|
|
183
181
|
bound: current.boundApps.has(app.id),
|
|
184
182
|
disabled: current.globalDisabledApps.has(app.id),
|
|
185
183
|
}))
|
|
186
184
|
.digest("hex");
|
|
187
185
|
}
|
|
186
|
+
/** The pinned package identity; when it was pinned does not change the selection. */
|
|
187
|
+
function selectedPin(id, project) {
|
|
188
|
+
const pin = parsePanelAppPackagePins(project ?? projectSettings())[id];
|
|
189
|
+
return pin ? { version: pin.version, packageDigest: pin.packageDigest } : null;
|
|
190
|
+
}
|
|
188
191
|
function projectSettings() {
|
|
189
192
|
return new SettingsManager(workspace, "full").getRawForScope("project", workspace, {
|
|
190
193
|
strict: true,
|
|
@@ -291,7 +294,7 @@ export function createPanelManagement(options) {
|
|
|
291
294
|
// Never attach a new revision to stale displayed binding state. File and
|
|
292
295
|
// package inspection above can yield to another device's mutation.
|
|
293
296
|
for (const app of [...panels, ...issues]) {
|
|
294
|
-
const pin = options.projectPackages ?
|
|
297
|
+
const pin = options.projectPackages ? currentPanelAppPackagePins(project)[app.id] : undefined;
|
|
295
298
|
if (projectState(app.id, project) !== projectState(app.id) ||
|
|
296
299
|
current.globalDisabledApps.has(app.id) !== policy().globalDisabledApps.has(app.id) ||
|
|
297
300
|
(pin && (pin.packageDigest !== app.packageDigest || pin.version !== app.version)))
|
|
@@ -484,7 +487,7 @@ export function createPanelManagement(options) {
|
|
|
484
487
|
return value;
|
|
485
488
|
});
|
|
486
489
|
}
|
|
487
|
-
async function mutate(context, change, work) {
|
|
490
|
+
async function mutate(context, change, work, prepare) {
|
|
488
491
|
const expected = generation(context.ownerId);
|
|
489
492
|
const guard = () => assertAuthorized(context, expected);
|
|
490
493
|
const run = async () => {
|
|
@@ -493,7 +496,11 @@ export function createPanelManagement(options) {
|
|
|
493
496
|
await guard();
|
|
494
497
|
return work(guard);
|
|
495
498
|
};
|
|
496
|
-
|
|
499
|
+
const matches = panelPackageMutationMatches({
|
|
500
|
+
...change,
|
|
501
|
+
projectPath: options.bindingCwd ?? options.cwd,
|
|
502
|
+
});
|
|
503
|
+
return panelExecutionGate.mutate(matches, () => (options.withMutation ? options.withMutation(write) : write()), prepare ? () => prepare(guard, matches) : undefined);
|
|
497
504
|
};
|
|
498
505
|
const next = catalogMutation.then(run, run);
|
|
499
506
|
catalogMutation = next.catch(() => undefined);
|
|
@@ -524,7 +531,20 @@ export function createPanelManagement(options) {
|
|
|
524
531
|
if (bound) {
|
|
525
532
|
if (!app.packageDigest)
|
|
526
533
|
throw new Error("Cannot bind an unverified Panel package");
|
|
527
|
-
pins[app.id]
|
|
534
|
+
const existing = pins[app.id];
|
|
535
|
+
// Rebinding the package a current pin already selects changes nothing, so
|
|
536
|
+
// it must not invalidate other devices' revisions or pending reviews.
|
|
537
|
+
const unchanged = existing?.version === app.version &&
|
|
538
|
+
existing.packageDigest === app.packageDigest &&
|
|
539
|
+
!!currentPanelAppPackagePins({ panelAppPins: { [app.id]: existing } })[app.id];
|
|
540
|
+
if (!unchanged)
|
|
541
|
+
pins[app.id] = {
|
|
542
|
+
version: app.version,
|
|
543
|
+
packageDigest: app.packageDigest,
|
|
544
|
+
// Holds this project only until the next global install or update, so it
|
|
545
|
+
// must be strictly later than the install it was chosen after.
|
|
546
|
+
pinnedAt: new Date(Math.max(now(), (installedPanelAppUpdatedAt(app.id) ?? 0) + 1)).toISOString(),
|
|
547
|
+
};
|
|
528
548
|
}
|
|
529
549
|
else
|
|
530
550
|
delete pins[app.id];
|
|
@@ -735,12 +755,18 @@ export function createPanelManagement(options) {
|
|
|
735
755
|
await assertAuthorized(context, held.generation);
|
|
736
756
|
if (approval?.expectedId && approval.expectedId !== held.public.preview.id)
|
|
737
757
|
invalid();
|
|
758
|
+
if (approval?.releaseExecutions && held.public.kind !== "update")
|
|
759
|
+
invalid();
|
|
738
760
|
if (approval && held.public.kind === "update" && !approval.overwrite)
|
|
739
761
|
throw new PanelManagementError(409, "already_installed", "这个面板已经安装,请确认项目更新。");
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (reviews.get(token) !== held || held.public.expiresAt <= now())
|
|
762
|
+
let admitted = false;
|
|
763
|
+
const assertReviewLease = () => {
|
|
764
|
+
if (!admitted && (reviews.get(token) !== held || held.public.expiresAt <= now()))
|
|
743
765
|
throw new PanelManagementError(409, "review_expired", "预览已失效,请重新检查来源。");
|
|
766
|
+
};
|
|
767
|
+
const validateReview = async (guard) => {
|
|
768
|
+
await assertAuthorized(context, held.generation);
|
|
769
|
+
assertReviewLease();
|
|
744
770
|
const id = held.public.preview.id;
|
|
745
771
|
if (held.public.kind === "update") {
|
|
746
772
|
const current = await selectedApp(id);
|
|
@@ -753,6 +779,11 @@ export function createPanelManagement(options) {
|
|
|
753
779
|
if (projectState(id) !== held.projectState)
|
|
754
780
|
throw new PanelManagementError(409, "conflict", "项目面板配置已改变,请重新预览。");
|
|
755
781
|
await guard();
|
|
782
|
+
assertReviewLease();
|
|
783
|
+
};
|
|
784
|
+
return mutate(context, { appId: held.public.preview.id, kind: held.public.kind }, async (guard) => {
|
|
785
|
+
await validateReview(guard);
|
|
786
|
+
const id = held.public.preview.id;
|
|
756
787
|
const installed = await installReviewedLocalPanelApp(held.source, held.digest, new Date(now()).toISOString(), {
|
|
757
788
|
overwrite: held.public.kind === "update",
|
|
758
789
|
expectedId: id,
|
|
@@ -782,7 +813,24 @@ export function createPanelManagement(options) {
|
|
|
782
813
|
invalidateSkillCache();
|
|
783
814
|
await options.onChanged?.(id, held.public.kind);
|
|
784
815
|
return { id, packageDigest: installed.packageDigest };
|
|
785
|
-
}
|
|
816
|
+
}, approval?.releaseExecutions
|
|
817
|
+
? async (guard, matches) => {
|
|
818
|
+
await validateReview(guard);
|
|
819
|
+
// A local directory/ZIP may have changed since preview. Check its
|
|
820
|
+
// reviewed bytes before cancelling work, then recheck mutable
|
|
821
|
+
// project/catalog authority after the source read.
|
|
822
|
+
const preview = await previewLocalPanelApp(held.source);
|
|
823
|
+
if (preview.id !== held.public.preview.id || preview.reviewToken !== held.digest)
|
|
824
|
+
throw new PanelManagementError(409, "conflict", "来源已改变,请重新检查更新。");
|
|
825
|
+
await validateReview(guard);
|
|
826
|
+
// This admitted operation owns the review while its holders exit.
|
|
827
|
+
// TTL expiry during cancellation must not invalidate that consent;
|
|
828
|
+
// owner revocation and package/project changes are still checked.
|
|
829
|
+
admitted = true;
|
|
830
|
+
reviews.delete(token);
|
|
831
|
+
await approval.releaseExecutions(held.public.preview.id, matches);
|
|
832
|
+
}
|
|
833
|
+
: undefined);
|
|
786
834
|
},
|
|
787
835
|
async binding(context, id, bound, expected) {
|
|
788
836
|
assertId(id);
|
|
@@ -793,11 +841,11 @@ export function createPanelManagement(options) {
|
|
|
793
841
|
const current = await selectedApp(id);
|
|
794
842
|
if (bound && !compatibility(current.app).supported)
|
|
795
843
|
throw new PanelManagementError(400, "unsupported", "这个面板需要当前 Web 环境未提供的能力。");
|
|
796
|
-
//
|
|
797
|
-
//
|
|
844
|
+
// An unpinned (or superseded) selection needs a retained copy before the
|
|
845
|
+
// project can pin it. A current pin must keep its selected version.
|
|
798
846
|
const app = bound &&
|
|
799
847
|
options.projectPackages &&
|
|
800
|
-
!
|
|
848
|
+
!currentPanelAppPackagePins(projectSettings())[current.app.id]
|
|
801
849
|
? await retainInstalledPanelApp(current.app.id, current.app.packageDigest)
|
|
802
850
|
: current.app;
|
|
803
851
|
await guard();
|
|
@@ -4,5 +4,5 @@ export interface PanelPackageMutation {
|
|
|
4
4
|
projectPath: string;
|
|
5
5
|
kind: "install" | "update" | "binding" | "remove";
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
7
|
+
/** A catalog install or update supersedes every project's pin, so it reaches every project. */
|
|
8
8
|
export declare function panelPackageMutationMatches(change: PanelPackageMutation): (scope: PanelExecutionScope) => boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolvePanelAppBindingProjectPath } from "@cjhyy/code-shell-core";
|
|
2
2
|
import { panelExecutionProject } from "./execution-gate.js";
|
|
3
|
-
/**
|
|
3
|
+
/** A catalog install or update supersedes every project's pin, so it reaches every project. */
|
|
4
4
|
export function panelPackageMutationMatches(change) {
|
|
5
5
|
const project = panelExecutionProject(resolvePanelAppBindingProjectPath(change.projectPath));
|
|
6
6
|
return (scope) => {
|
|
@@ -11,13 +11,6 @@ export function panelPackageMutationMatches(change) {
|
|
|
11
11
|
const bindingProject = resolvePanelAppBindingProjectPath(scope.projectPath);
|
|
12
12
|
if (panelExecutionProject(bindingProject) === project)
|
|
13
13
|
return true;
|
|
14
|
-
|
|
15
|
-
return false;
|
|
16
|
-
try {
|
|
17
|
-
return !projectPanelAppPackagePins(bindingProject, change.appId)[change.appId];
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
return true;
|
|
21
|
-
} // An unreadable project cannot prove that it is insulated.
|
|
14
|
+
return change.kind !== "binding";
|
|
22
15
|
};
|
|
23
16
|
}
|
|
@@ -17,15 +17,15 @@ import { describeEnvironment, environmentIdentity } from "../environment-identit
|
|
|
17
17
|
// server restart spawns a fresh worker on the first inbound frame and the
|
|
18
18
|
// browser re-lists sessions over the same protocol.
|
|
19
19
|
import { createServer } from "node:http";
|
|
20
|
-
import {
|
|
21
|
-
import { basename, dirname,
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
21
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
22
22
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
23
23
|
import { WebSocketServer } from "ws";
|
|
24
24
|
import { CONTRACTS, SessionManager, SettingsManager } from "@cjhyy/code-shell-core";
|
|
25
25
|
import { SessionOutputCoverageStore, readOutputJournal, readOutputJournalLegacyBase, } from "@cjhyy/code-shell-core/internal";
|
|
26
26
|
import { AccessPasscode } from "../mobile-remote/access-passcode.js";
|
|
27
27
|
import { resolveSafe } from "../mobile-remote/mobile-static.js";
|
|
28
|
-
import {
|
|
28
|
+
import { streamStaticFile } from "../static-files.js";
|
|
29
29
|
import { createHubAuth } from "../hub/auth-http.js";
|
|
30
30
|
import { ApprovalLeases } from "../hub/approval-lease.js";
|
|
31
31
|
import { HubUploads, hubJson } from "../hub/uploads.js";
|
|
@@ -611,7 +611,7 @@ export async function startHeadlessServer(opts) {
|
|
|
611
611
|
isAuthorized: configurationOptions.isAuthorized,
|
|
612
612
|
})
|
|
613
613
|
: undefined;
|
|
614
|
-
const serveStatic = (req, res) => {
|
|
614
|
+
const serveStatic = async (req, res) => {
|
|
615
615
|
const root = opts.staticRootDir;
|
|
616
616
|
if (!root) {
|
|
617
617
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
@@ -635,27 +635,24 @@ export async function startHeadlessServer(opts) {
|
|
|
635
635
|
res.end("not found");
|
|
636
636
|
return;
|
|
637
637
|
}
|
|
638
|
-
filePath =
|
|
639
|
-
if (!
|
|
638
|
+
filePath = resolveSafe(root, "index.html");
|
|
639
|
+
if (!filePath) {
|
|
640
640
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
641
641
|
res.end("web app not built");
|
|
642
642
|
return;
|
|
643
643
|
}
|
|
644
644
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
});
|
|
651
|
-
res.end(body);
|
|
652
|
-
}
|
|
653
|
-
catch {
|
|
654
|
-
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
655
|
-
res.end("not found");
|
|
656
|
-
}
|
|
645
|
+
await streamStaticFile(req, res, {
|
|
646
|
+
rootDir: root,
|
|
647
|
+
filePath,
|
|
648
|
+
cacheControl: "no-cache",
|
|
649
|
+
});
|
|
657
650
|
};
|
|
658
651
|
const handleHttp = async (req, res) => {
|
|
652
|
+
if (closing) {
|
|
653
|
+
hubJson(res, 503, { error: "server is closing" });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
659
656
|
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
660
657
|
res.setHeader("x-content-type-options", "nosniff");
|
|
661
658
|
res.setHeader("referrer-policy", "no-referrer");
|
|
@@ -678,14 +675,16 @@ export async function startHeadlessServer(opts) {
|
|
|
678
675
|
return;
|
|
679
676
|
}
|
|
680
677
|
if (hubAuth) {
|
|
681
|
-
if (await links?.handlePublic(req, res))
|
|
678
|
+
if ((await links?.handlePublic(req, res)) || closing || res.destroyed)
|
|
682
679
|
return;
|
|
683
|
-
if (await panels?.handleAssets(req, res))
|
|
680
|
+
if ((await panels?.handleAssets(req, res)) || closing || res.destroyed)
|
|
684
681
|
return;
|
|
685
|
-
if (await hubAuth.handle(req, res))
|
|
682
|
+
if ((await hubAuth.handle(req, res)) || closing || res.destroyed)
|
|
686
683
|
return;
|
|
687
684
|
if (pathname.startsWith("/api/")) {
|
|
688
685
|
const session = await hubAuth.authenticate(req);
|
|
686
|
+
if (closing || res.destroyed)
|
|
687
|
+
return;
|
|
689
688
|
if (!session) {
|
|
690
689
|
hubJson(res, 401, { error: "login required" });
|
|
691
690
|
return;
|
|
@@ -694,10 +693,6 @@ export async function startHeadlessServer(opts) {
|
|
|
694
693
|
hubJson(res, 403, { error: "origin rejected" });
|
|
695
694
|
return;
|
|
696
695
|
}
|
|
697
|
-
if (closing) {
|
|
698
|
-
hubJson(res, 503, { error: "server is closing" });
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
696
|
responses.track(session.id, res);
|
|
702
697
|
if (pathname === "/api/v1/environment" && req.method === "GET") {
|
|
703
698
|
hubJson(res, 200, environment);
|
|
@@ -738,20 +733,37 @@ export async function startHeadlessServer(opts) {
|
|
|
738
733
|
return;
|
|
739
734
|
}
|
|
740
735
|
// Public static shell contains no user data and supplies the setup/login page.
|
|
741
|
-
serveStatic(req, res);
|
|
736
|
+
await serveStatic(req, res);
|
|
742
737
|
return;
|
|
743
738
|
}
|
|
744
739
|
if (!passcode.gate(req, res))
|
|
745
740
|
return;
|
|
746
|
-
serveStatic(req, res);
|
|
741
|
+
await serveStatic(req, res);
|
|
747
742
|
};
|
|
748
743
|
const server = createServer((req, res) => {
|
|
749
744
|
void handleHttp(req, res).catch(() => hubJson(res, 500, { error: "server request failed" }));
|
|
750
745
|
});
|
|
746
|
+
// HTTP closeAllConnections excludes an upgrade awaiting authentication.
|
|
747
|
+
// Own accepted sockets until close or a completed transfer to WebSocket.
|
|
748
|
+
const connections = new Set();
|
|
749
|
+
server.on("connection", (socket) => {
|
|
750
|
+
connections.add(socket);
|
|
751
|
+
socket.once("close", () => connections.delete(socket));
|
|
752
|
+
if (closing)
|
|
753
|
+
socket.destroy();
|
|
754
|
+
});
|
|
751
755
|
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
752
756
|
const upgrade = async (req, socket, head) => {
|
|
757
|
+
if (closing || socket.destroyed) {
|
|
758
|
+
socket.destroy();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
753
761
|
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
754
762
|
const session = hubAuth ? await hubAuth.authenticate(req) : undefined;
|
|
763
|
+
if (closing || socket.destroyed) {
|
|
764
|
+
socket.destroy();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
755
767
|
const allowed = hubAuth
|
|
756
768
|
? !!session && hubAuth.isOriginAllowed(req)
|
|
757
769
|
: passcode.allows(req);
|
|
@@ -767,6 +779,7 @@ export async function startHeadlessServer(opts) {
|
|
|
767
779
|
}
|
|
768
780
|
const tabId = nextTabId++;
|
|
769
781
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
782
|
+
connections.delete(socket);
|
|
770
783
|
tabs.add(ws);
|
|
771
784
|
if (session)
|
|
772
785
|
tabAuth.set(ws, { sessionId: session.id, request: req });
|
|
@@ -792,7 +805,7 @@ export async function startHeadlessServer(opts) {
|
|
|
792
805
|
ws.close(1009, "payload too large");
|
|
793
806
|
return;
|
|
794
807
|
}
|
|
795
|
-
if (closing) {
|
|
808
|
+
if (closing || ws.readyState !== ws.OPEN) {
|
|
796
809
|
ws.close(1012, "server stopping");
|
|
797
810
|
return;
|
|
798
811
|
}
|
|
@@ -943,9 +956,13 @@ export async function startHeadlessServer(opts) {
|
|
|
943
956
|
sendToTab(ws, hostQueryError(parsed.id ?? null, -32602, error.message), "attachments");
|
|
944
957
|
return;
|
|
945
958
|
}
|
|
946
|
-
if (ws.readyState !== ws.OPEN
|
|
959
|
+
if (closing || ws.readyState !== ws.OPEN)
|
|
960
|
+
return;
|
|
961
|
+
if (!(await hubAuth.authenticate(req)))
|
|
947
962
|
return;
|
|
948
963
|
}
|
|
964
|
+
if (closing || ws.readyState !== ws.OPEN)
|
|
965
|
+
return;
|
|
949
966
|
// Recheck after asynchronous staging/authentication; other tabs may
|
|
950
967
|
// have filled the runtime's capacity while this request was suspended.
|
|
951
968
|
if (hubAuth && parsed.method === "agent/run" && runningSessions.size >= 64) {
|
|
@@ -1060,6 +1077,8 @@ export async function startHeadlessServer(opts) {
|
|
|
1060
1077
|
const workerLine = JSON.stringify(workerMessage);
|
|
1061
1078
|
// Spawn-on-first-frame (idempotent): the browser's first request wakes
|
|
1062
1079
|
// the worker, mirroring the renderer's spawn-on-agent/run semantics.
|
|
1080
|
+
if (closing || ws.readyState !== ws.OPEN)
|
|
1081
|
+
return;
|
|
1063
1082
|
bridge.ensureWorker(opts.cwd);
|
|
1064
1083
|
if (!bridge.canSend()) {
|
|
1065
1084
|
workerUnavailable("agent worker is unavailable");
|
|
@@ -1114,6 +1133,58 @@ export async function startHeadlessServer(opts) {
|
|
|
1114
1133
|
server.on("upgrade", (req, socket, head) => {
|
|
1115
1134
|
void upgrade(req, socket, head).catch(() => socket.destroy());
|
|
1116
1135
|
});
|
|
1136
|
+
let closePromise;
|
|
1137
|
+
const close = () => {
|
|
1138
|
+
if (closePromise)
|
|
1139
|
+
return closePromise;
|
|
1140
|
+
closing = true;
|
|
1141
|
+
closePromise = Promise.resolve().then(async () => {
|
|
1142
|
+
// Stop admission before any asynchronous resource/worker cleanup. A
|
|
1143
|
+
// reconnect during a slow worker exit must not prolong server shutdown.
|
|
1144
|
+
const webSocketsClosed = new Promise((resolve) => wss.close(() => resolve()));
|
|
1145
|
+
// Finish WebSocket teardown before the HTTP close callback, including on
|
|
1146
|
+
// Bun where closing HTTP before its upgraded sockets can strand it.
|
|
1147
|
+
const httpClosed = webSocketsClosed.then(() => new Promise((resolve) => {
|
|
1148
|
+
server.closeAllConnections?.();
|
|
1149
|
+
server.close(() => resolve());
|
|
1150
|
+
}));
|
|
1151
|
+
for (const tab of tabs)
|
|
1152
|
+
tab.terminate();
|
|
1153
|
+
for (const socket of connections)
|
|
1154
|
+
socket.destroy();
|
|
1155
|
+
tabs.clear();
|
|
1156
|
+
responses.clear();
|
|
1157
|
+
clearInterval(pendingResponseReaper);
|
|
1158
|
+
if (authReaper)
|
|
1159
|
+
clearInterval(authReaper);
|
|
1160
|
+
// A failed cleanup must not leave the remaining services or worker alive.
|
|
1161
|
+
const errors = [];
|
|
1162
|
+
const clean = async (action) => {
|
|
1163
|
+
try {
|
|
1164
|
+
await action();
|
|
1165
|
+
}
|
|
1166
|
+
catch (error) {
|
|
1167
|
+
errors.push(error);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
await clean(() => panelAutomations?.close());
|
|
1171
|
+
await clean(() => {
|
|
1172
|
+
hostRuntime.stop();
|
|
1173
|
+
});
|
|
1174
|
+
await clean(() => uploads?.close());
|
|
1175
|
+
await clean(() => skills?.close());
|
|
1176
|
+
await clean(() => mcp?.close());
|
|
1177
|
+
await clean(() => configuration?.close());
|
|
1178
|
+
await clean(() => links?.close());
|
|
1179
|
+
await clean(() => panels?.close());
|
|
1180
|
+
await clean(() => files?.close());
|
|
1181
|
+
await clean(() => bridge.stopAndWait());
|
|
1182
|
+
await Promise.all([httpClosed, webSocketsClosed]);
|
|
1183
|
+
if (errors.length)
|
|
1184
|
+
throw new AggregateError(errors, "Headless server shutdown failed");
|
|
1185
|
+
});
|
|
1186
|
+
return closePromise;
|
|
1187
|
+
};
|
|
1117
1188
|
try {
|
|
1118
1189
|
if (hubAuth) {
|
|
1119
1190
|
automationWorker = createHubAutomationWorker({
|
|
@@ -1187,23 +1258,18 @@ export async function startHeadlessServer(opts) {
|
|
|
1187
1258
|
});
|
|
1188
1259
|
}
|
|
1189
1260
|
catch (error) {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
await panels?.close();
|
|
1203
|
-
files?.close();
|
|
1204
|
-
await bridge.stopAndWait();
|
|
1205
|
-
wss.close();
|
|
1206
|
-
server.close();
|
|
1261
|
+
const failures = [error];
|
|
1262
|
+
try {
|
|
1263
|
+
await close();
|
|
1264
|
+
}
|
|
1265
|
+
catch (cleanupError) {
|
|
1266
|
+
failures.push(cleanupError);
|
|
1267
|
+
}
|
|
1268
|
+
if (failures.length > 1) {
|
|
1269
|
+
throw new AggregateError(failures, "Headless server startup and shutdown failed", {
|
|
1270
|
+
cause: error,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1207
1273
|
throw error;
|
|
1208
1274
|
}
|
|
1209
1275
|
const address = server.address();
|
|
@@ -1220,39 +1286,7 @@ export async function startHeadlessServer(opts) {
|
|
|
1220
1286
|
bridge,
|
|
1221
1287
|
tabCount: () => tabs.size,
|
|
1222
1288
|
pendingResponseCount: () => pendingWorkerResponses.size,
|
|
1223
|
-
close
|
|
1224
|
-
closing = true;
|
|
1225
|
-
responses.clear();
|
|
1226
|
-
await panelAutomations?.close();
|
|
1227
|
-
clearInterval(pendingResponseReaper);
|
|
1228
|
-
if (authReaper)
|
|
1229
|
-
clearInterval(authReaper);
|
|
1230
|
-
await uploads?.close();
|
|
1231
|
-
await skills?.close();
|
|
1232
|
-
await mcp?.close();
|
|
1233
|
-
configuration?.close();
|
|
1234
|
-
links?.close();
|
|
1235
|
-
await panels?.close();
|
|
1236
|
-
files?.close();
|
|
1237
|
-
for (const tab of tabs) {
|
|
1238
|
-
try {
|
|
1239
|
-
tab.terminate();
|
|
1240
|
-
}
|
|
1241
|
-
catch {
|
|
1242
|
-
/* ignore */
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
tabs.clear();
|
|
1246
|
-
await bridge.stopAndWait();
|
|
1247
|
-
await new Promise((resolve) => {
|
|
1248
|
-
wss.close(() => {
|
|
1249
|
-
server.close(() => resolve());
|
|
1250
|
-
});
|
|
1251
|
-
// server.close() alone waits out keep-alive HTTP sockets forever;
|
|
1252
|
-
// drop them so shutdown is prompt.
|
|
1253
|
-
server.closeAllConnections?.();
|
|
1254
|
-
});
|
|
1255
|
-
},
|
|
1289
|
+
close,
|
|
1256
1290
|
};
|
|
1257
1291
|
}
|
|
1258
1292
|
function webSocketPayloadBytes(data) {
|
package/dist/static-files.d.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
1
2
|
export declare function contentTypeFor(extname: string): string;
|
|
3
|
+
/** Stream a pinned regular file, bounded by its opened size and the response lifetime. */
|
|
4
|
+
export declare function streamStaticFile(req: IncomingMessage, res: ServerResponse, options: {
|
|
5
|
+
rootDir: string;
|
|
6
|
+
filePath: string;
|
|
7
|
+
cacheControl: string;
|
|
8
|
+
}): Promise<void>;
|
package/dist/static-files.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// Shared static-file primitives for the two HTTP hosts (pairing host's
|
|
4
4
|
// /mobile static root and the headless serve SPA root). Keep this file free
|
|
5
5
|
// of host wiring — MIME map + helpers only.
|
|
6
|
+
import { constants } from "node:fs";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
9
|
+
import { extname, isAbsolute, relative, sep } from "node:path";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
6
11
|
const CONTENT_TYPES = {
|
|
7
12
|
".html": "text/html; charset=utf-8",
|
|
8
13
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -24,3 +29,112 @@ const CONTENT_TYPES = {
|
|
|
24
29
|
export function contentTypeFor(extname) {
|
|
25
30
|
return CONTENT_TYPES[extname.toLowerCase()] ?? "application/octet-stream";
|
|
26
31
|
}
|
|
32
|
+
/** RFC 9110 entity-tag lists allow commas inside opaque tags and empty list items. */
|
|
33
|
+
function matchesIfNoneMatch(value, etag) {
|
|
34
|
+
if (typeof value !== "string")
|
|
35
|
+
return false;
|
|
36
|
+
const header = value.replace(/^[ \t]+|[ \t]+$/g, "");
|
|
37
|
+
if (header === "*")
|
|
38
|
+
return true;
|
|
39
|
+
const tag = /(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")/y;
|
|
40
|
+
const opaque = etag.slice(2);
|
|
41
|
+
let position = 0;
|
|
42
|
+
let matches = false;
|
|
43
|
+
while (position < header.length) {
|
|
44
|
+
while (/[ \t,]/.test(header[position] ?? "") && position < header.length)
|
|
45
|
+
position++;
|
|
46
|
+
if (position === header.length)
|
|
47
|
+
break;
|
|
48
|
+
tag.lastIndex = position;
|
|
49
|
+
const item = tag.exec(header);
|
|
50
|
+
if (!item)
|
|
51
|
+
return false;
|
|
52
|
+
matches ||= item[1] === opaque;
|
|
53
|
+
position = tag.lastIndex;
|
|
54
|
+
while (/[ \t]/.test(header[position] ?? "") && position < header.length)
|
|
55
|
+
position++;
|
|
56
|
+
if (position < header.length && header[position++] !== ",")
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
// Validate the entire field before accepting a match, including later items.
|
|
60
|
+
return matches;
|
|
61
|
+
}
|
|
62
|
+
/** Stream a pinned regular file, bounded by its opened size and the response lifetime. */
|
|
63
|
+
export async function streamStaticFile(req, res, options) {
|
|
64
|
+
let handle;
|
|
65
|
+
try {
|
|
66
|
+
if (res.destroyed || res.writableEnded)
|
|
67
|
+
return;
|
|
68
|
+
const root = await realpath(options.rootDir);
|
|
69
|
+
const path = await realpath(options.filePath);
|
|
70
|
+
const contained = (target) => {
|
|
71
|
+
const rel = relative(root, target);
|
|
72
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
73
|
+
};
|
|
74
|
+
if (!contained(path))
|
|
75
|
+
throw new Error("Static asset is outside its root");
|
|
76
|
+
// Resolve aliases before opening so valid in-root symlinks remain supported.
|
|
77
|
+
// O_NONBLOCK avoids hanging on a regular file replaced by a FIFO.
|
|
78
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
79
|
+
const info = await handle.stat({ bigint: true });
|
|
80
|
+
if (!info.isFile())
|
|
81
|
+
throw new Error("Static asset is not a regular file");
|
|
82
|
+
if (info.size > BigInt(Number.MAX_SAFE_INTEGER))
|
|
83
|
+
throw new Error("Static asset is too large");
|
|
84
|
+
const currentPath = await realpath(options.filePath);
|
|
85
|
+
const current = await stat(currentPath, { bigint: true });
|
|
86
|
+
if (!contained(currentPath) ||
|
|
87
|
+
(await realpath(options.rootDir)) !== root ||
|
|
88
|
+
current.dev !== info.dev ||
|
|
89
|
+
current.ino !== info.ino ||
|
|
90
|
+
current.size !== info.size ||
|
|
91
|
+
current.mtimeNs !== info.mtimeNs ||
|
|
92
|
+
current.ctimeNs !== info.ctimeNs)
|
|
93
|
+
throw new Error("Static asset changed while opening");
|
|
94
|
+
if (res.destroyed || res.writableEnded)
|
|
95
|
+
return;
|
|
96
|
+
// A weak validator describes the opened file's metadata, not a byte hash.
|
|
97
|
+
// Include identity and change time so replacement or an equal-size rewrite
|
|
98
|
+
// with a restored mtime does not reuse the previous representation's tag.
|
|
99
|
+
const fingerprint = createHash("sha256")
|
|
100
|
+
.update(`${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`)
|
|
101
|
+
.digest("base64url");
|
|
102
|
+
const etag = `W/"${fingerprint}"`;
|
|
103
|
+
const headers = {
|
|
104
|
+
"content-type": contentTypeFor(extname(options.filePath)),
|
|
105
|
+
"cache-control": options.cacheControl,
|
|
106
|
+
"x-content-type-options": "nosniff",
|
|
107
|
+
etag,
|
|
108
|
+
};
|
|
109
|
+
if ((req.method === "GET" || req.method === "HEAD") &&
|
|
110
|
+
matchesIfNoneMatch(req.headers["if-none-match"], etag)) {
|
|
111
|
+
res.writeHead(304, headers);
|
|
112
|
+
res.end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
res.writeHead(200, { ...headers, "content-length": info.size.toString() });
|
|
116
|
+
if (req.method === "HEAD" || info.size === 0n) {
|
|
117
|
+
res.end();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// pipeline propagates backpressure and destroys the source on disconnect.
|
|
121
|
+
// The explicit end prevents a growing file from extending this response.
|
|
122
|
+
await pipeline(handle.createReadStream({ autoClose: false, start: 0, end: Number(info.size - 1n) }), res);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
if (res.destroyed || res.writableEnded)
|
|
126
|
+
return;
|
|
127
|
+
if (res.headersSent)
|
|
128
|
+
res.destroy();
|
|
129
|
+
else {
|
|
130
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
131
|
+
res.end("not found");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
await handle?.close().catch(() => {
|
|
136
|
+
if (!res.destroyed)
|
|
137
|
+
res.destroy();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cjhyy/code-shell-server",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.48",
|
|
4
4
|
"description": "CodeShell server transport layer — mobile remote HTTP/WS host, pairing, passcode gate, tunnel, rooms, uploads, and session/attachment services. Electron-free; precursor of the auth gateway.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@cjhyy/code-shell-capability-coding": "0.9.
|
|
66
|
-
"@cjhyy/code-shell-core": "0.9.
|
|
67
|
-
"@cjhyy/code-shell-web": "0.9.
|
|
65
|
+
"@cjhyy/code-shell-capability-coding": "0.9.48",
|
|
66
|
+
"@cjhyy/code-shell-core": "0.9.48",
|
|
67
|
+
"@cjhyy/code-shell-web": "0.9.48",
|
|
68
68
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
69
69
|
"@types/ws": "^8.18.1",
|
|
70
70
|
"ws": "^8.21.0",
|
|
71
|
-
"@cjhyy/code-shell-link": "0.9.
|
|
72
|
-
"@cjhyy/code-shell-host": "0.9.
|
|
71
|
+
"@cjhyy/code-shell-link": "0.9.48",
|
|
72
|
+
"@cjhyy/code-shell-host": "0.9.48"
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
75
|
"node": ">=20.10"
|