@cjhyy/code-shell-server 0.9.47 → 0.9.49

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.
Files changed (57) hide show
  1. package/README.md +4 -0
  2. package/dist/desktop-web/http-api.d.ts +9 -0
  3. package/dist/desktop-web/http-api.js +17 -1
  4. package/dist/http-byte-range.d.ts +5 -0
  5. package/dist/http-byte-range.js +19 -0
  6. package/dist/hub/auth-store.d.ts +2 -0
  7. package/dist/hub/auth-store.js +26 -7
  8. package/dist/hub/files.js +71 -9
  9. package/dist/index.panels.d.ts +1 -0
  10. package/dist/index.panels.js +1 -0
  11. package/dist/mobile-remote/access-passcode.d.ts +5 -0
  12. package/dist/mobile-remote/access-passcode.js +48 -9
  13. package/dist/mobile-remote/codex-room-agent.js +3 -1
  14. package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
  15. package/dist/mobile-remote/mobile-chat-turn.js +15 -2
  16. package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
  17. package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
  18. package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
  19. package/dist/mobile-remote/mobile-static.d.ts +5 -6
  20. package/dist/mobile-remote/mobile-static.js +13 -14
  21. package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
  22. package/dist/mobile-remote/remote-host-manager.js +132 -56
  23. package/dist/mobile-remote/resident-agent.js +3 -1
  24. package/dist/mobile-remote/room-manager.d.ts +4 -0
  25. package/dist/mobile-remote/room-manager.js +5 -0
  26. package/dist/mobile-remote/room-process-stop.d.ts +8 -0
  27. package/dist/mobile-remote/room-process-stop.js +18 -0
  28. package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
  29. package/dist/mobile-remote/tunnel-manager.js +45 -20
  30. package/dist/panels/background-service-process.d.ts +39 -0
  31. package/dist/panels/background-service-process.js +457 -0
  32. package/dist/panels/background-service-store.d.ts +67 -0
  33. package/dist/panels/background-service-store.js +539 -0
  34. package/dist/panels/background-services.d.ts +193 -0
  35. package/dist/panels/background-services.js +1159 -0
  36. package/dist/panels/bridge-contract.d.ts +1 -1
  37. package/dist/panels/execution-gate.d.ts +10 -2
  38. package/dist/panels/execution-gate.js +36 -2
  39. package/dist/panels/http.d.ts +1 -0
  40. package/dist/panels/management-http.d.ts +1 -0
  41. package/dist/panels/management.d.ts +3 -0
  42. package/dist/panels/management.js +105 -21
  43. package/dist/panels/package-mutation.d.ts +1 -1
  44. package/dist/panels/package-mutation.js +3 -10
  45. package/dist/panels/process-files.js +28 -3
  46. package/dist/panels/resources/library.d.ts +1 -4
  47. package/dist/panels/resources/library.js +2 -18
  48. package/dist/panels/resources/references.js +21 -8
  49. package/dist/panels/runtime.js +1 -1
  50. package/dist/panels/tool-jobs.d.ts +2 -0
  51. package/dist/panels/tool-jobs.js +1 -1
  52. package/dist/serve/headless-server.js +113 -79
  53. package/dist/sessions-service.d.ts +4 -0
  54. package/dist/sessions-service.js +17 -0
  55. package/dist/static-files.d.ts +7 -0
  56. package/dist/static-files.js +114 -0
  57. 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:
@@ -34,8 +34,17 @@ export interface DesktopWebApiOptions {
34
34
  sessionTtlMs?: number;
35
35
  }
36
36
  export interface DesktopWebHttpApi {
37
+ /**
38
+ * Optional independent authorization for GET/HEAD Panel assets. A handler may
39
+ * serve only an existing short-lived capability after revalidating its owner
40
+ * and project grant. Return false without writing when no capability matches;
41
+ * the caller must then apply its ordinary access gate. Never accepts API calls.
42
+ */
43
+ handleCapabilityAsset?: (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
37
44
  handle: (request: IncomingMessage, response: ServerResponse, context: {
38
45
  baseUrl: string;
46
+ /** Recheck the entrance authorization captured after its access gate succeeded. */
47
+ isAccessAuthorized?: () => boolean;
39
48
  }) => Promise<boolean>;
40
49
  /** Reopen after RemoteHostManager.stop(), without reviving previous cookies. */
41
50
  start: () => void;
@@ -163,6 +163,8 @@ export function createDesktopWebApi(options) {
163
163
  if (!identity || closed || identity.generation !== generation)
164
164
  return false;
165
165
  try {
166
+ if (identity.isAccessAuthorized?.() === false)
167
+ return false;
166
168
  if (principal(identity.token)?.session !== identity.session)
167
169
  return false;
168
170
  const resolved = await options.resolveWorkspace(identity.input, identity.session.deviceId);
@@ -170,6 +172,7 @@ export function createDesktopWebApi(options) {
170
172
  return (resolved === identity.cwd &&
171
173
  !closed &&
172
174
  identity.generation === generation &&
175
+ identity.isAccessAuthorized?.() !== false &&
173
176
  principal(identity.token)?.session === identity.session);
174
177
  }
175
178
  catch {
@@ -302,6 +305,8 @@ export function createDesktopWebApi(options) {
302
305
  try {
303
306
  if (closed)
304
307
  throw new HubConfigurationError(503, "桌面远程服务已停止。");
308
+ if (context.isAccessAuthorized?.() === false)
309
+ throw new HubConfigurationError(401, "访问口令已更新,请重新连接桌面。");
305
310
  const requestGeneration = generation;
306
311
  const origin = sameOrigin(request, context.baseUrl);
307
312
  const secure = origin.protocol === "https:";
@@ -310,6 +315,8 @@ export function createDesktopWebApi(options) {
310
315
  const input = await credentials(request);
311
316
  if (closed || requestGeneration !== generation)
312
317
  throw new HubConfigurationError(503, "桌面远程服务已停止。");
318
+ if (context.isAccessAuthorized?.() === false)
319
+ throw new HubConfigurationError(401, "访问口令已更新,请重新连接桌面。");
313
320
  const device = options.devices.authenticate(input.deviceId, input.secretHash);
314
321
  if (!device)
315
322
  throw new HubConfigurationError(401, "设备未配对或已被撤销,请在桌面重新配对。");
@@ -360,6 +367,7 @@ export function createDesktopWebApi(options) {
360
367
  const id = await environmentIdentity(options.dataDir);
361
368
  if (closed ||
362
369
  generation !== requestGeneration ||
370
+ context.isAccessAuthorized?.() === false ||
363
371
  principal(token)?.session !== current.session)
364
372
  throw new HubConfigurationError(401, "设备连接已失效,请重新连接桌面。");
365
373
  json(response, 200, describeEnvironment(id, "desktop"));
@@ -369,11 +377,19 @@ export function createDesktopWebApi(options) {
369
377
  const cwd = await options.resolveWorkspace(input, current.device.id);
370
378
  if (closed ||
371
379
  generation !== requestGeneration ||
380
+ context.isAccessAuthorized?.() === false ||
372
381
  principal(token)?.session !== current.session)
373
382
  throw new HubConfigurationError(401, "设备连接已失效,请重新连接桌面。");
374
383
  if (!cwd || !isAbsolute(cwd))
375
384
  throw new HubConfigurationError(403, "只能访问桌面已知的项目或会话工作区。");
376
- identities.set(request, { token, session: current.session, input, cwd, generation });
385
+ identities.set(request, {
386
+ token,
387
+ session: current.session,
388
+ input,
389
+ cwd,
390
+ generation,
391
+ isAccessAuthorized: context.isAccessAuthorized,
392
+ });
377
393
  const services = servicesFor(cwd);
378
394
  services.active++;
379
395
  try {
@@ -0,0 +1,5 @@
1
+ /** A bounded single HTTP byte range. Multipart ranges are deliberately unsupported. */
2
+ export declare function rangeBounds(range: string, size: number): {
3
+ start: number;
4
+ end: number;
5
+ } | null;
@@ -0,0 +1,19 @@
1
+ /** A bounded single HTTP byte range. Multipart ranges are deliberately unsupported. */
2
+ export function rangeBounds(range, size) {
3
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
4
+ if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(size) || size <= 0)
5
+ return null;
6
+ const first = match[1] ? Number(match[1]) : undefined;
7
+ const last = match[2] ? Number(match[2]) : undefined;
8
+ if ((first !== undefined && !Number.isSafeInteger(first)) ||
9
+ (last !== undefined && !Number.isSafeInteger(last)))
10
+ return null;
11
+ if (first === undefined) {
12
+ if (!last || last <= 0)
13
+ return null;
14
+ return { start: Math.max(0, size - last), end: size - 1 };
15
+ }
16
+ if (first >= size || (last !== undefined && last < first))
17
+ return null;
18
+ return { start: first, end: Math.min(last ?? size - 1, size - 1) };
19
+ }
@@ -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
  }
@@ -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
- const tokenHash = hashToken(token);
99
- const session = record.sessions.find((row) => safeHashEqual(row.tokenHash, tokenHash));
100
- if (!record.account || !session || session.expiresAt <= now || session.createdAt > now) {
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
- const metadata = lstatSync(parent);
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;
package/dist/hub/files.js CHANGED
@@ -2,8 +2,10 @@ import { constants } from "node:fs";
2
2
  import { lstat, open, opendir, realpath } from "node:fs/promises";
3
3
  import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { pipeline } from "node:stream/promises";
5
+ import { Readable } from "node:stream";
6
+ import { rangeBounds } from "../http-byte-range.js";
5
7
  import { hubJson } from "./uploads.js";
6
- const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
8
+ const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
7
9
  const MAX_PREVIEW_BYTES = 512 * 1024;
8
10
  const IMAGE_TYPES = {
9
11
  ".png": "image/png",
@@ -121,7 +123,7 @@ export function createHubFiles(options) {
121
123
  try {
122
124
  if (closed)
123
125
  throw new FileRequestError(503, "服务正在关闭。");
124
- if (req.method !== "GET")
126
+ if (req.method !== "GET" && !(req.method === "HEAD" && url.pathname.endsWith("/content")))
125
127
  throw new FileRequestError(405, "文件页面只支持读取。");
126
128
  if (!(await options.isAuthorized(req)))
127
129
  throw new FileRequestError(401, "请重新登录。");
@@ -184,14 +186,16 @@ export function createHubFiles(options) {
184
186
  if (!(await options.isAuthorized(req)))
185
187
  throw new FileRequestError(401, "请重新登录。");
186
188
  await opened.verify();
189
+ if (!(await options.isAuthorized(req)))
190
+ throw new FileRequestError(401, "请重新登录。");
187
191
  hubJson(res, 200, { path: location.relativePath, files, truncated });
188
192
  }
189
193
  else {
190
- if (info.size > MAX_DOWNLOAD_BYTES)
191
- throw new FileRequestError(413, "文件超过 100 MB,请从服务器工作目录获取。");
192
194
  if (!(await options.isAuthorized(req)))
193
195
  throw new FileRequestError(401, "请重新登录。");
194
196
  await opened.verify();
197
+ if (!(await options.isAuthorized(req)))
198
+ throw new FileRequestError(401, "请重新登录。");
195
199
  const imageType = IMAGE_TYPES[extname(location.path).toLowerCase()];
196
200
  const preview = url.searchParams.get("preview") === "true";
197
201
  if (preview) {
@@ -199,6 +203,9 @@ export function createHubFiles(options) {
199
203
  const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
200
204
  const bytes = buffer.subarray(0, bytesRead);
201
205
  const binary = bytes.includes(0) || !!imageType;
206
+ await opened.verify();
207
+ if (!(await options.isAuthorized(req)))
208
+ throw new FileRequestError(401, "请重新登录。");
202
209
  hubJson(res, 200, {
203
210
  path: location.relativePath,
204
211
  name: basename(location.path),
@@ -211,21 +218,76 @@ export function createHubFiles(options) {
211
218
  });
212
219
  }
213
220
  else {
221
+ if (info.size > MAX_DOWNLOAD_BYTES)
222
+ throw new FileRequestError(413, "文件超过 2 GiB 下载上限。");
214
223
  const inline = url.searchParams.get("inline") === "true" && !!imageType;
215
224
  const filename = basename(location.path);
216
225
  const encoded = encodeURIComponent(filename).replace(/[!'()*]/g, (value) => `%${value.charCodeAt(0).toString(16).toUpperCase()}`);
217
- res.writeHead(200, {
226
+ // Workspace outputs are mutable; without a matching validator an
227
+ // If-Range request must receive the complete current representation.
228
+ const range = req.headers["if-range"] === undefined ? req.headers.range : undefined;
229
+ const bounds = range === undefined ? { start: 0, end: info.size - 1 } : rangeBounds(range, info.size);
230
+ const headers = {
218
231
  "content-type": inline ? imageType : "application/octet-stream",
219
- "content-length": info.size,
220
232
  "content-disposition": `${inline ? "inline" : "attachment"}; filename="download${extname(filename).replace(/[^.A-Za-z0-9]/g, "")}"; filename*=UTF-8''${encoded}`,
233
+ "accept-ranges": "bytes",
221
234
  "cache-control": "no-store",
222
235
  "x-content-type-options": "nosniff",
223
236
  "content-security-policy": "default-src 'none'; sandbox",
237
+ "referrer-policy": "no-referrer",
238
+ };
239
+ if (!bounds) {
240
+ res.writeHead(416, {
241
+ ...headers,
242
+ "content-range": `bytes */${info.size}`,
243
+ "content-length": 0,
244
+ });
245
+ res.end();
246
+ return true;
247
+ }
248
+ res.writeHead(range === undefined ? 200 : 206, {
249
+ ...headers,
250
+ "content-length": bounds.end - bounds.start + 1,
251
+ ...(range === undefined
252
+ ? {}
253
+ : { "content-range": `bytes ${bounds.start}-${bounds.end}/${info.size}` }),
224
254
  });
225
- if (info.size === 0)
255
+ if (req.method === "HEAD" || info.size === 0)
226
256
  res.end();
227
- else
228
- await pipeline(handle.createReadStream({ autoClose: false, end: Math.max(0, info.size - 1) }), res);
257
+ else {
258
+ const input = handle.createReadStream({ autoClose: false, ...bounds });
259
+ const authorize = async () => {
260
+ if (closed || res.destroyed || !(await options.isAuthorized(req)))
261
+ throw new FileRequestError(401, "下载授权已失效。");
262
+ };
263
+ let checking = false;
264
+ const timer = setInterval(() => {
265
+ if (checking)
266
+ return;
267
+ checking = true;
268
+ void authorize()
269
+ .catch(() => {
270
+ input.destroy();
271
+ res.destroy();
272
+ })
273
+ .finally(() => {
274
+ checking = false;
275
+ });
276
+ }, 250);
277
+ timer.unref();
278
+ try {
279
+ await pipeline(Readable.from((async function* () {
280
+ for await (const chunk of input) {
281
+ await authorize();
282
+ yield chunk;
283
+ }
284
+ })(), { objectMode: false }), res);
285
+ }
286
+ finally {
287
+ clearInterval(timer);
288
+ input.destroy();
289
+ }
290
+ }
229
291
  }
230
292
  }
231
293
  }
@@ -26,3 +26,4 @@ export * from "./panels/task-cookie-host.js";
26
26
  export * from "./panels/execution-gate.js";
27
27
  export * from "./panels/package-mutation.js";
28
28
  export * from "./panels/model-traces.js";
29
+ export * from "./panels/background-services.js";
@@ -25,3 +25,4 @@ export * from "./panels/task-cookie-host.js";
25
25
  export * from "./panels/execution-gate.js";
26
26
  export * from "./panels/package-mutation.js";
27
27
  export * from "./panels/model-traces.js";
28
+ export * from "./panels/background-services.js";
@@ -49,6 +49,11 @@ export declare class AccessPasscode {
49
49
  private lockedUntil;
50
50
  constructor(opts: AccessPasscodeOptions);
51
51
  isSet(): boolean;
52
+ /**
53
+ * After a successful gate, retain its passcode generation for derived grants.
54
+ * Rechecks never retain/retry the plaintext or spend the verification budget.
55
+ */
56
+ captureValidity(): () => boolean;
52
57
  /** Set (or rotate) the passcode. Rotating `secret` invalidates old tokens. */
53
58
  set(passcode: string): void;
54
59
  /**
@@ -11,6 +11,10 @@ const SCRYPT_KEYLEN = 32;
11
11
  export const MIN_ACCESS_PASSCODE_LENGTH = 4;
12
12
  export const MAX_ACCESS_PASSCODE_LENGTH = 256;
13
13
  const MAX_REMEMBER_TOKEN_LENGTH = 128;
14
+ /** Marks which gate decision produced a response: "granted", "denied" or
15
+ * "unconfigured". The challenge page reports a passcode outcome only from this
16
+ * marker, never from a status a tunnel edge or proxy could also produce. */
17
+ const ACCESS_OUTCOME_HEADER = "X-CodeShell-Access";
14
18
  const MAX_ACCESS_RECORD_BYTES = 4 * 1024;
15
19
  /**
16
20
  * The public-tunnel access gate. A single shared passcode (set by the desktop
@@ -49,6 +53,14 @@ export class AccessPasscode {
49
53
  isSet() {
50
54
  return this.read() !== undefined;
51
55
  }
56
+ /**
57
+ * After a successful gate, retain its passcode generation for derived grants.
58
+ * Rechecks never retain/retry the plaintext or spend the verification budget.
59
+ */
60
+ captureValidity() {
61
+ const secret = this.read()?.secret;
62
+ return () => secret !== undefined && this.read()?.secret === secret;
63
+ }
52
64
  /** Set (or rotate) the passcode. Rotating `secret` invalidates old tokens. */
53
65
  set(passcode) {
54
66
  if (!isValidPasscode(passcode)) {
@@ -132,13 +144,18 @@ export class AccessPasscode {
132
144
  gate(req, res) {
133
145
  if (!this.isSet()) {
134
146
  // No passcode configured → tunnel must not be reachable. Refuse.
135
- res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
147
+ res.writeHead(403, {
148
+ "content-type": "text/plain; charset=utf-8",
149
+ [ACCESS_OUTCOME_HEADER]: "unconfigured",
150
+ });
136
151
  res.end("access passcode not configured");
137
152
  return false;
138
153
  }
139
154
  const cookieToken = readCookie(req.headers.cookie, COOKIE_NAME);
140
- if (cookieToken && this.verifyToken(cookieToken))
155
+ if (cookieToken && this.verifyToken(cookieToken)) {
156
+ res.setHeader(ACCESS_OUTCOME_HEADER, "granted");
141
157
  return true;
158
+ }
142
159
  const supplied = readPasscodeParam(req);
143
160
  let wrongAttempt = false;
144
161
  if (supplied) {
@@ -151,6 +168,7 @@ export class AccessPasscode {
151
168
  // user every visit. Lax sends it on top-level navigations. Secure
152
169
  // because the tunnel terminates TLS (always https).
153
170
  `${COOKIE_NAME}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${Math.floor(this.tokenMaxAgeMs / 1000)}`);
171
+ res.setHeader(ACCESS_OUTCOME_HEADER, "granted");
154
172
  const cleanLocation = acceptsHtml(req) ? locationWithoutPasscode(req.url) : undefined;
155
173
  if (cleanLocation) {
156
174
  // The HTML challenge has a no-query JavaScript path below, but keep a
@@ -176,11 +194,17 @@ export class AccessPasscode {
176
194
  // to enter the passcode). Non-navigation requests (WS upgrade, fetch) keep
177
195
  // the lightweight text 401.
178
196
  if (acceptsHtml(req)) {
179
- res.writeHead(401, { "content-type": "text/html; charset=utf-8" });
197
+ res.writeHead(401, {
198
+ "content-type": "text/html; charset=utf-8",
199
+ [ACCESS_OUTCOME_HEADER]: "denied",
200
+ });
180
201
  res.end(challengePage(req.url, wrongAttempt, this.isLocked()));
181
202
  return false;
182
203
  }
183
- res.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
204
+ res.writeHead(401, {
205
+ "content-type": "text/plain; charset=utf-8",
206
+ [ACCESS_OUTCOME_HEADER]: "denied",
207
+ });
184
208
  res.end("访问口令无效或缺失");
185
209
  return false;
186
210
  }
@@ -457,17 +481,32 @@ function challengePage(url, wrong, locked) {
457
481
  const value = input?.value ?? "";
458
482
  if (!value) return;
459
483
  try {
484
+ // Request the page as the following navigation will (HTML), so SPA
485
+ // routes resolve the same way. A passcode outcome is reported only from
486
+ // the gate's own marker: a tunnel edge or proxy can answer 4xx/5xx
487
+ // without the request ever reaching the gate.
460
488
  const response = await fetch(window.location.href, {
461
- headers: { "X-Access-Passcode": value, Accept: "application/json" },
489
+ headers: { "X-Access-Passcode": value, Accept: "text/html" },
462
490
  credentials: "same-origin",
463
491
  });
464
- if (!response.ok) {
492
+ if (response.ok) {
493
+ window.location.replace(${cleanLocation});
494
+ return;
495
+ }
496
+ const access = response.headers.get("${ACCESS_OUTCOME_HEADER}");
497
+ const status = "(HTTP " + response.status + ")";
498
+ if (access === "denied") {
465
499
  if (note) note.textContent = "口令不正确或尝试过多,请稍后重试。";
466
- if (note) note.className = "err";
467
500
  if (input) input.value = "";
468
- return;
501
+ } else if (note) {
502
+ note.textContent =
503
+ access === "granted"
504
+ ? "访问验证已通过,但页面加载失败" + status + ",请稍后重试或检查桌面端。"
505
+ : access === "unconfigured"
506
+ ? "远程访问未就绪" + status + ",请在桌面端检查手机遥控设置。"
507
+ : "请求未完成" + status + ",口令尚未得到验证,请稍后重试。";
469
508
  }
470
- window.location.replace(${cleanLocation});
509
+ if (note) note.className = "err";
471
510
  } catch {
472
511
  if (note) note.textContent = "连接失败,请重试。";
473
512
  if (note) note.className = "err";
@@ -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 = { ok: false, message: "The selected runtime rejected the submission" };
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 { ok: false, message: acceptance.message };
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");
@@ -29,6 +29,9 @@ export function parseMobileClientEvent(value) {
29
29
  case "chat.send":
30
30
  valid =
31
31
  boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
32
+ (event.modelKey === undefined ||
33
+ (boundedString(event.modelKey, MAX_ID_LENGTH) &&
34
+ /^(codex|claude-code)\/[^\r\n\0]*$/.test(event.modelKey))) &&
32
35
  optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
33
36
  (event.modelTraceSource === undefined ||
34
37
  (typeof event.modelTraceSource === "string" &&
@@ -52,6 +55,17 @@ export function parseMobileClientEvent(value) {
52
55
  case "session.recovery.cancel":
53
56
  valid = true;
54
57
  break;
58
+ case "session.outputRecovery.plan":
59
+ case "session.outputRecovery.apply":
60
+ valid =
61
+ boundedString(event.sessionId, 128) &&
62
+ boundedString(event.requestId, 128) &&
63
+ (event.type === "session.outputRecovery.plan" ||
64
+ (boundedString(event.planId, 128) && event.acceptHistoricalGap === true)) &&
65
+ Object.keys(event).every((key) => (event.type === "session.outputRecovery.plan"
66
+ ? ["type", "sessionId", "requestId"]
67
+ : ["type", "sessionId", "requestId", "planId", "acceptHistoricalGap"]).includes(key));
68
+ break;
55
69
  case "session.legacyBase":
56
70
  valid =
57
71
  boundedString(event.sessionId, 128) &&
@@ -97,6 +111,15 @@ export function parseMobileClientEvent(value) {
97
111
  optionalMember(event.pathScope, PATH_SCOPES);
98
112
  break;
99
113
  case "session.list":
114
+ valid =
115
+ optionalBoundedString(event.requestId, 128) &&
116
+ optionalBoundedString(event.cursor, 2048) &&
117
+ (event.cwds === undefined ||
118
+ (Array.isArray(event.cwds) &&
119
+ event.cwds.length > 0 &&
120
+ event.cwds.length <= 256 &&
121
+ event.cwds.every(boundedPath)));
122
+ break;
100
123
  case "room.list":
101
124
  case "room.projects":
102
125
  valid = true;
@@ -115,6 +138,9 @@ export function parseMobileClientEvent(value) {
115
138
  case "model.set":
116
139
  valid = boundedString(event.model, MAX_ID_LENGTH);
117
140
  break;
141
+ case "runtime.models":
142
+ valid = true;
143
+ break;
118
144
  case "goal.extend":
119
145
  valid =
120
146
  boundedString(event.sessionId, MAX_ID_LENGTH) &&
@@ -164,7 +190,10 @@ export function parseMobileClientEvent(value) {
164
190
  optionalMember(event.kind, KINDS);
165
191
  break;
166
192
  case "ccRoom.listSessions":
167
- valid = boundedPath(event.cwd) && optionalMember(event.kind, KINDS);
193
+ valid =
194
+ boundedPath(event.cwd) &&
195
+ optionalMember(event.kind, KINDS) &&
196
+ (event.all === undefined || typeof event.all === "boolean");
168
197
  break;
169
198
  case "ccRoom.openSession":
170
199
  valid =
@@ -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
- bridge.injectWorkerMessage(JSON.stringify({
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
- }), meta);
50
+ });
51
+ injecting = true;
52
+ bridge.injectWorkerMessage(line, meta);
51
53
  }
52
54
  catch (error) {
53
- finish({ ok: false, message: error instanceof Error ? error.message : String(error) });
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
  }