@cjhyy/code-shell-server 0.9.48 → 0.9.50
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/dist/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/mobile-client-event-validator.js +54 -4
- package/dist/mobile-remote/pending-approvals.d.ts +13 -1
- package/dist/mobile-remote/pending-approvals.js +45 -9
- package/dist/mobile-remote/remote-host-manager.d.ts +11 -1
- package/dist/mobile-remote/remote-host-manager.js +140 -56
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +7 -1
- package/dist/panels/execution-gate.js +26 -0
- package/dist/panels/management.js +49 -13
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +19 -21
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +2 -2
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/package.json +6 -6
|
@@ -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, {
|
|
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,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
|
+
}
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/index.panels.d.ts
CHANGED
package/dist/index.panels.js
CHANGED
|
@@ -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, {
|
|
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, {
|
|
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, {
|
|
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: "
|
|
489
|
+
headers: { "X-Access-Passcode": value, Accept: "text/html" },
|
|
462
490
|
credentials: "same-origin",
|
|
463
491
|
});
|
|
464
|
-
if (
|
|
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
|
-
|
|
501
|
+
} else if (note) {
|
|
502
|
+
note.textContent =
|
|
503
|
+
access === "granted"
|
|
504
|
+
? "访问验证已通过,但页面加载失败" + status + ",请稍后重试或检查桌面端。"
|
|
505
|
+
: access === "unconfigured"
|
|
506
|
+
? "远程访问未就绪" + status + ",请在桌面端检查手机遥控设置。"
|
|
507
|
+
: "请求未完成" + status + ",口令尚未得到验证,请稍后重试。";
|
|
469
508
|
}
|
|
470
|
-
|
|
509
|
+
if (note) note.className = "err";
|
|
471
510
|
} catch {
|
|
472
511
|
if (note) note.textContent = "连接失败,请重试。";
|
|
473
512
|
if (note) note.className = "err";
|
|
@@ -28,7 +28,11 @@ export function parseMobileClientEvent(value) {
|
|
|
28
28
|
break;
|
|
29
29
|
case "chat.send":
|
|
30
30
|
valid =
|
|
31
|
-
|
|
31
|
+
optionalMember(event.permissionMode, PERMISSION_MODES) &&
|
|
32
|
+
boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
|
|
33
|
+
(event.modelKey === undefined ||
|
|
34
|
+
(boundedString(event.modelKey, MAX_ID_LENGTH) &&
|
|
35
|
+
/^(codex|claude-code)\/[^\r\n\0]*$/.test(event.modelKey))) &&
|
|
32
36
|
optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
33
37
|
(event.modelTraceSource === undefined ||
|
|
34
38
|
(typeof event.modelTraceSource === "string" &&
|
|
@@ -52,6 +56,17 @@ export function parseMobileClientEvent(value) {
|
|
|
52
56
|
case "session.recovery.cancel":
|
|
53
57
|
valid = true;
|
|
54
58
|
break;
|
|
59
|
+
case "session.outputRecovery.plan":
|
|
60
|
+
case "session.outputRecovery.apply":
|
|
61
|
+
valid =
|
|
62
|
+
boundedString(event.sessionId, 128) &&
|
|
63
|
+
boundedString(event.requestId, 128) &&
|
|
64
|
+
(event.type === "session.outputRecovery.plan" ||
|
|
65
|
+
(boundedString(event.planId, 128) && event.acceptHistoricalGap === true)) &&
|
|
66
|
+
Object.keys(event).every((key) => (event.type === "session.outputRecovery.plan"
|
|
67
|
+
? ["type", "sessionId", "requestId"]
|
|
68
|
+
: ["type", "sessionId", "requestId", "planId", "acceptHistoricalGap"]).includes(key));
|
|
69
|
+
break;
|
|
55
70
|
case "session.legacyBase":
|
|
56
71
|
valid =
|
|
57
72
|
boundedString(event.sessionId, 128) &&
|
|
@@ -60,20 +75,37 @@ export function parseMobileClientEvent(value) {
|
|
|
60
75
|
((boundedString(event.throughEventId, 512) && event.cursor === undefined) ||
|
|
61
76
|
(boundedString(event.cursor, 2048) && event.throughEventId === undefined));
|
|
62
77
|
break;
|
|
78
|
+
case "session.historyView":
|
|
79
|
+
case "session.historyDetail":
|
|
80
|
+
valid =
|
|
81
|
+
boundedString(event.sessionId, 128) &&
|
|
82
|
+
boundedString(event.recoveryId, 128) &&
|
|
83
|
+
boundedString(event.requestId, 128) &&
|
|
84
|
+
(event.type === "session.historyView"
|
|
85
|
+
? optionalBoundedString(event.before, 2048) &&
|
|
86
|
+
Object.keys(event).every((key) => ["type", "sessionId", "recoveryId", "requestId", "before"].includes(key))
|
|
87
|
+
: boundedString(event.ref, 2048) &&
|
|
88
|
+
optionalBoundedString(event.cursor, 2048) &&
|
|
89
|
+
Object.keys(event).every((key) => ["type", "sessionId", "recoveryId", "requestId", "ref", "cursor"].includes(key)));
|
|
90
|
+
break;
|
|
63
91
|
case "session.outputJournal":
|
|
64
92
|
valid =
|
|
65
93
|
boundedString(event.sessionId, 128) &&
|
|
66
94
|
boundedString(event.recoveryId, 128) &&
|
|
67
95
|
boundedString(event.requestId, 128) &&
|
|
68
96
|
optionalBoundedString(event.after, 2048) &&
|
|
69
|
-
optionalBoundedString(event.through, 2048)
|
|
97
|
+
optionalBoundedString(event.through, 2048) &&
|
|
98
|
+
(event.frozenSnapshot === undefined || event.frozenSnapshot === true) &&
|
|
99
|
+
(event.maxFrames === undefined ||
|
|
100
|
+
(isPositiveInteger(event.maxFrames) && event.maxFrames <= 2048));
|
|
70
101
|
break;
|
|
71
102
|
case "session.history":
|
|
72
103
|
valid = boundedString(event.sessionId, MAX_ID_LENGTH);
|
|
73
104
|
break;
|
|
74
105
|
case "session.create":
|
|
75
106
|
valid =
|
|
76
|
-
|
|
107
|
+
optionalMember(event.permissionMode, PERMISSION_MODES) &&
|
|
108
|
+
optionalBoundedString(event.clientRequestId, MAX_ID_LENGTH) &&
|
|
77
109
|
(event.cwd === undefined || event.cwd === null || boundedPath(event.cwd)) &&
|
|
78
110
|
(event.projectId === undefined ||
|
|
79
111
|
event.projectId === null ||
|
|
@@ -91,12 +123,24 @@ export function parseMobileClientEvent(value) {
|
|
|
91
123
|
boundedString(event.approvalId, MAX_ID_LENGTH) &&
|
|
92
124
|
(event.decision === "approve" || event.decision === "reject") &&
|
|
93
125
|
optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
126
|
+
optionalBoundedString(event.connectionId, MAX_ID_LENGTH) &&
|
|
127
|
+
(event.generation === undefined ||
|
|
128
|
+
(Number.isSafeInteger(event.generation) && event.generation >= 0)) &&
|
|
94
129
|
optionalBoundedString(event.reason, MAX_MESSAGE_LENGTH) &&
|
|
95
130
|
optionalBoundedString(event.answer, MAX_MESSAGE_LENGTH) &&
|
|
96
131
|
optionalMember(event.scope, SCOPES) &&
|
|
97
132
|
optionalMember(event.pathScope, PATH_SCOPES);
|
|
98
133
|
break;
|
|
99
134
|
case "session.list":
|
|
135
|
+
valid =
|
|
136
|
+
optionalBoundedString(event.requestId, 128) &&
|
|
137
|
+
optionalBoundedString(event.cursor, 2048) &&
|
|
138
|
+
(event.cwds === undefined ||
|
|
139
|
+
(Array.isArray(event.cwds) &&
|
|
140
|
+
event.cwds.length > 0 &&
|
|
141
|
+
event.cwds.length <= 256 &&
|
|
142
|
+
event.cwds.every(boundedPath)));
|
|
143
|
+
break;
|
|
100
144
|
case "room.list":
|
|
101
145
|
case "room.projects":
|
|
102
146
|
valid = true;
|
|
@@ -115,6 +159,9 @@ export function parseMobileClientEvent(value) {
|
|
|
115
159
|
case "model.set":
|
|
116
160
|
valid = boundedString(event.model, MAX_ID_LENGTH);
|
|
117
161
|
break;
|
|
162
|
+
case "runtime.models":
|
|
163
|
+
valid = true;
|
|
164
|
+
break;
|
|
118
165
|
case "goal.extend":
|
|
119
166
|
valid =
|
|
120
167
|
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
@@ -164,7 +211,10 @@ export function parseMobileClientEvent(value) {
|
|
|
164
211
|
optionalMember(event.kind, KINDS);
|
|
165
212
|
break;
|
|
166
213
|
case "ccRoom.listSessions":
|
|
167
|
-
valid =
|
|
214
|
+
valid =
|
|
215
|
+
boundedPath(event.cwd) &&
|
|
216
|
+
optionalMember(event.kind, KINDS) &&
|
|
217
|
+
(event.all === undefined || typeof event.all === "boolean");
|
|
168
218
|
break;
|
|
169
219
|
case "ccRoom.openSession":
|
|
170
220
|
valid =
|
|
@@ -5,15 +5,27 @@
|
|
|
5
5
|
* are stateful: keep the latest unresolved raw JSON-RPC request here and replay
|
|
6
6
|
* it to a phone when that phone binds to the matching session.
|
|
7
7
|
*/
|
|
8
|
+
export interface PendingMobileApprovalRoute {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
requestId: string;
|
|
11
|
+
connectionId?: string;
|
|
12
|
+
generation?: number;
|
|
13
|
+
expiresAt?: number;
|
|
14
|
+
}
|
|
8
15
|
export declare class PendingMobileApprovals {
|
|
16
|
+
private readonly now;
|
|
9
17
|
private readonly bySession;
|
|
10
18
|
private workerGeneration;
|
|
19
|
+
constructor(now?: () => number);
|
|
11
20
|
/** Requests belong to the worker that can answer them, never its successor. */
|
|
12
21
|
setWorkerState(generation: number, alive: boolean): void;
|
|
13
22
|
observeOutboundLine(line: string): void;
|
|
14
|
-
resolve(requestId: string): void;
|
|
23
|
+
resolve(requestId: string, route?: Partial<PendingMobileApprovalRoute>): void;
|
|
15
24
|
replayLines(sessionId: string): string[];
|
|
25
|
+
/** The stored worker envelope, rather than a phone's mutable current session, owns routing. */
|
|
26
|
+
find(sessionId: string, requestId: string): PendingMobileApprovalRoute | undefined;
|
|
16
27
|
/** The desktop has a queue across sessions; return only its current pending requests. */
|
|
17
28
|
replayAllLines(): string[];
|
|
18
29
|
forgetSession(sessionId: string): void;
|
|
30
|
+
private pruneExpired;
|
|
19
31
|
}
|
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tracks currently pending core approval requests for mobile clients.
|
|
3
|
-
*
|
|
4
|
-
* Session snapshots intentionally contain only stream events. Approval requests
|
|
5
|
-
* are stateful: keep the latest unresolved raw JSON-RPC request here and replay
|
|
6
|
-
* it to a phone when that phone binds to the matching session.
|
|
7
|
-
*/
|
|
8
1
|
export class PendingMobileApprovals {
|
|
2
|
+
now;
|
|
9
3
|
bySession = new Map();
|
|
10
4
|
workerGeneration;
|
|
5
|
+
constructor(now = Date.now) {
|
|
6
|
+
this.now = now;
|
|
7
|
+
}
|
|
11
8
|
/** Requests belong to the worker that can answer them, never its successor. */
|
|
12
9
|
setWorkerState(generation, alive) {
|
|
13
10
|
if (!alive || this.workerGeneration !== generation)
|
|
@@ -32,6 +29,8 @@ export class PendingMobileApprovals {
|
|
|
32
29
|
return;
|
|
33
30
|
if (typeof requestId !== "string" || !requestId)
|
|
34
31
|
return;
|
|
32
|
+
if (typeof params?.expiresAt === "number" && params.expiresAt <= this.now())
|
|
33
|
+
return;
|
|
35
34
|
let pending = this.bySession.get(sessionId);
|
|
36
35
|
if (!pending) {
|
|
37
36
|
pending = new Map();
|
|
@@ -44,24 +43,61 @@ export class PendingMobileApprovals {
|
|
|
44
43
|
const params = msg.params;
|
|
45
44
|
const requestId = params?.requestId;
|
|
46
45
|
if (typeof requestId === "string" && requestId)
|
|
47
|
-
this.resolve(requestId);
|
|
46
|
+
this.resolve(requestId, params ?? {});
|
|
48
47
|
}
|
|
49
48
|
}
|
|
50
|
-
resolve(requestId) {
|
|
49
|
+
resolve(requestId, route = {}) {
|
|
51
50
|
for (const [sessionId, pending] of this.bySession) {
|
|
51
|
+
if (route.sessionId !== undefined && route.sessionId !== sessionId)
|
|
52
|
+
continue;
|
|
53
|
+
const line = pending.get(requestId);
|
|
54
|
+
if (!line)
|
|
55
|
+
continue;
|
|
56
|
+
const params = JSON.parse(line).params;
|
|
57
|
+
if ((params.connectionId !== undefined || params.generation !== undefined) &&
|
|
58
|
+
(route.sessionId !== sessionId ||
|
|
59
|
+
route.connectionId !== params.connectionId ||
|
|
60
|
+
route.generation !== params.generation))
|
|
61
|
+
continue;
|
|
62
|
+
if ((route.connectionId !== undefined && route.connectionId !== params.connectionId) ||
|
|
63
|
+
(route.generation !== undefined && route.generation !== params.generation))
|
|
64
|
+
continue;
|
|
52
65
|
pending.delete(requestId);
|
|
53
66
|
if (pending.size === 0)
|
|
54
67
|
this.bySession.delete(sessionId);
|
|
55
68
|
}
|
|
56
69
|
}
|
|
57
70
|
replayLines(sessionId) {
|
|
71
|
+
this.pruneExpired();
|
|
58
72
|
return [...(this.bySession.get(sessionId)?.values() ?? [])];
|
|
59
73
|
}
|
|
74
|
+
/** The stored worker envelope, rather than a phone's mutable current session, owns routing. */
|
|
75
|
+
find(sessionId, requestId) {
|
|
76
|
+
this.pruneExpired();
|
|
77
|
+
const line = this.bySession.get(sessionId)?.get(requestId);
|
|
78
|
+
if (!line)
|
|
79
|
+
return undefined;
|
|
80
|
+
const { connectionId, generation, expiresAt } = JSON.parse(line).params;
|
|
81
|
+
return { sessionId, requestId, connectionId, generation, expiresAt };
|
|
82
|
+
}
|
|
60
83
|
/** The desktop has a queue across sessions; return only its current pending requests. */
|
|
61
84
|
replayAllLines() {
|
|
85
|
+
this.pruneExpired();
|
|
62
86
|
return [...this.bySession.values()].flatMap((pending) => [...pending.values()]);
|
|
63
87
|
}
|
|
64
88
|
forgetSession(sessionId) {
|
|
65
89
|
this.bySession.delete(sessionId);
|
|
66
90
|
}
|
|
91
|
+
pruneExpired() {
|
|
92
|
+
const now = this.now();
|
|
93
|
+
for (const [sessionId, pending] of this.bySession) {
|
|
94
|
+
for (const [requestId, line] of pending) {
|
|
95
|
+
const expiresAt = JSON.parse(line).params.expiresAt;
|
|
96
|
+
if (typeof expiresAt === "number" && expiresAt <= now)
|
|
97
|
+
pending.delete(requestId);
|
|
98
|
+
}
|
|
99
|
+
if (!pending.size)
|
|
100
|
+
this.bySession.delete(sessionId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
67
103
|
}
|