@cjhyy/code-shell-server 0.9.29 → 0.9.30

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.
@@ -8,7 +8,7 @@ export declare const REMOTE_LINK_ADAPTERS: ({
8
8
  id: "github";
9
9
  name: "GitHub";
10
10
  group: "repositories";
11
- actions: readonly ["list_repositories", "get_readme", "get_file", "list_issues", "get_issue", "list_pull_requests", "get_pull_request", "create_issue", "get_repository", "get_starred", "set_starred"];
11
+ actions: readonly ["list_repositories", "get_readme", "get_file", "list_issues", "get_issue", "list_pull_requests", "get_pull_request", "create_issue", "update_issue", "get_repository", "get_starred", "set_starred"];
12
12
  } | {
13
13
  methodId: string;
14
14
  authModeId: string;
@@ -20,10 +20,14 @@ export async function readRemoteProviderCatalog(configuration) {
20
20
  /** A legacy v1 service has no catalog. Its one already-reviewed adapter remains compatible. */
21
21
  export async function readRemoteProviderCapabilities(configuration) {
22
22
  const issuer = beginRemoteLinkAuthorization(configuration).configuration.issuer;
23
+ const scopes = REMOTE_LINK_ADAPTERS.flatMap((adapter) => adapter.scopes);
24
+ const capabilities = JSON.stringify({ version: 1, scopes });
25
+ if (scopes.length > 128 || Buffer.byteLength(capabilities) > 4_096)
26
+ throw new Error("Host Link capabilities exceed catalog negotiation limits");
23
27
  const response = await fetch(new URL("/api/v1/links/providers", issuer), {
24
28
  redirect: "error",
25
29
  signal: AbortSignal.timeout(5_000),
26
- headers: { Accept: "application/json" },
30
+ headers: { Accept: "application/json", "X-CodeShell-Link-Capabilities": capabilities },
27
31
  });
28
32
  if ([404, 501].includes(response.status)) {
29
33
  await response.body?.cancel();
@@ -1,5 +1,5 @@
1
1
  import { markAttachmentsSent, type InputAttachmentMeta } from "../attachment-service.js";
2
- import { type MobileRunBridge } from "./mobile-run-dispatch.js";
2
+ import { type MobileRunBridge, type MobileRunAcceptance, type MobileRunRequest } from "./mobile-run-dispatch.js";
3
3
  import type { MobileUploadService } from "./mobile-upload-service.js";
4
4
  import type { MobileAttachmentSummary, MobileImageAttachment, PermissionMode } from "./types.js";
5
5
  import type { WorkerFrameMeta } from "../worker-bridge-core.js";
@@ -20,6 +20,8 @@ export interface DispatchMobileChatTurnInput {
20
20
  uploads: ChatTurnUploads;
21
21
  resolveWorkspace: (sessionId: string, fallbackCwd: string) => Promise<string>;
22
22
  markSent?: typeof markAttachmentsSent;
23
+ /** Host-owned alternative producer; receives the same materialized input and ack boundary. */
24
+ submit?: (request: MobileRunRequest) => Promise<MobileRunAcceptance>;
23
25
  }
24
26
  export type DispatchMobileChatTurnResult = {
25
27
  ok: true;
@@ -1,7 +1,7 @@
1
1
  import { markAttachmentsSent } from "../attachment-service.js";
2
2
  import { stablePromptHash } from "../client-message-id.js";
3
3
  import { materializeMobileAttachments } from "./mobile-attachments.js";
4
- import { injectMobileRunAndAwaitAcceptance } from "./mobile-run-dispatch.js";
4
+ import { injectMobileRunAndAwaitAcceptance, } from "./mobile-run-dispatch.js";
5
5
  async function settleClaims(uploads, deviceId, claims, action) {
6
6
  await Promise.allSettled(claims.map((claim) => uploads[action](deviceId, claim.uploadId, claim.claimId)));
7
7
  }
@@ -40,7 +40,7 @@ export async function dispatchMobileChatTurn(input) {
40
40
  }
41
41
  const clientMessageId = suppliedClientMessageId ||
42
42
  `mobile:${input.sessionId}:${input.runId}:${stablePromptHash(`${text}\0${attachmentHash}`)}`;
43
- const acceptance = await injectMobileRunAndAwaitAcceptance(input.bridge, {
43
+ const request = {
44
44
  id: input.runId,
45
45
  params: {
46
46
  task: text,
@@ -52,7 +52,16 @@ export async function dispatchMobileChatTurn(input) {
52
52
  attachments: materialized.metas,
53
53
  ...(input.permissionMode ? { permissionMode: input.permissionMode } : {}),
54
54
  },
55
- }, input.meta);
55
+ };
56
+ let acceptance;
57
+ try {
58
+ acceptance = input.submit
59
+ ? await input.submit(request)
60
+ : await injectMobileRunAndAwaitAcceptance(input.bridge, request, input.meta);
61
+ }
62
+ catch {
63
+ acceptance = { ok: false, message: "The selected runtime rejected the submission" };
64
+ }
56
65
  if (!acceptance.ok) {
57
66
  await settleClaims(input.uploads, input.deviceId, materialized.claims, "release");
58
67
  return { ok: false, message: acceptance.message };
@@ -41,6 +41,21 @@ export function parseMobileClientEvent(value) {
41
41
  event.size <= MAX_MOBILE_IMAGE_BYTES;
42
42
  break;
43
43
  case "session.select":
44
+ valid =
45
+ boundedString(event.sessionId, MAX_ID_LENGTH) &&
46
+ optionalBoundedString(event.recoveryId, 128);
47
+ break;
48
+ case "session.recovery.cancel":
49
+ valid = true;
50
+ break;
51
+ case "session.outputJournal":
52
+ valid =
53
+ boundedString(event.sessionId, 128) &&
54
+ boundedString(event.recoveryId, 128) &&
55
+ boundedString(event.requestId, 128) &&
56
+ optionalBoundedString(event.after, 2048) &&
57
+ optionalBoundedString(event.through, 2048);
58
+ break;
44
59
  case "session.history":
45
60
  valid = boundedString(event.sessionId, MAX_ID_LENGTH);
46
61
  break;
@@ -36,6 +36,7 @@ export interface RemoteHostStarted {
36
36
  }
37
37
  export interface RemoteHostManagerOptions {
38
38
  devices: TrustedDeviceStore;
39
+ outputJournal?: boolean;
39
40
  onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
40
41
  /**
41
42
  * Absolute path to the built mobile app (out/mobile). Defaults to the
@@ -116,6 +117,9 @@ export declare class RemoteHostManager extends EventEmitter {
116
117
  * one phone's session/permission state never leaks onto another's screen.
117
118
  * A device with no live socket (raced a disconnect) is a silent no-op. */
118
119
  sendToDevice(deviceId: string, event: MobileServerEvent): void;
120
+ /** Reply only to the currently authenticated tab; retired/revoked viewers receive nothing. */
121
+ hasAuthenticatedViewer(viewerId: string, deviceId: string): boolean;
122
+ sendToViewer(viewerId: string, event: MobileServerEvent): void;
119
123
  /** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
120
124
  sendRawToDevice(deviceId: string, payload: string): void;
121
125
  /** Broadcast a raw line (e.g. a mirrored worker→renderer JSON-RPC line) to
@@ -406,7 +406,11 @@ export class RemoteHostManager extends EventEmitter {
406
406
  if (!device) {
407
407
  return { type: "pair.failed", message: "Pairing token expired or invalid" };
408
408
  }
409
- return { type: "pair.ok", device };
409
+ return {
410
+ type: "pair.ok",
411
+ device,
412
+ ...(this.opts.outputJournal ? { capabilities: { outputJournal: 1 } } : {}),
413
+ };
410
414
  }
411
415
  if (event.type === "auth.device") {
412
416
  let device;
@@ -418,7 +422,11 @@ export class RemoteHostManager extends EventEmitter {
418
422
  }
419
423
  if (!device)
420
424
  return { type: "auth.failed", message: "Device is not trusted" };
421
- return { type: "auth.ok", device };
425
+ return {
426
+ type: "auth.ok",
427
+ device,
428
+ ...(this.opts.outputJournal ? { capabilities: { outputJournal: 1 } } : {}),
429
+ };
422
430
  }
423
431
  return undefined;
424
432
  }
@@ -438,6 +446,33 @@ export class RemoteHostManager extends EventEmitter {
438
446
  }
439
447
  }
440
448
  }
449
+ /** Reply only to the currently authenticated tab; retired/revoked viewers receive nothing. */
450
+ hasAuthenticatedViewer(viewerId, deviceId) {
451
+ for (const client of this.wss?.clients ?? []) {
452
+ if (client.readyState === client.OPEN &&
453
+ this.authed.get(client) === deviceId &&
454
+ this.viewers.get(client) === viewerId)
455
+ return true;
456
+ }
457
+ return false;
458
+ }
459
+ sendToViewer(viewerId, event) {
460
+ const payload = JSON.stringify(event);
461
+ for (const client of this.wss?.clients ?? []) {
462
+ if (client.readyState === client.OPEN &&
463
+ this.authed.has(client) &&
464
+ this.viewers.get(client) === viewerId) {
465
+ // A selected viewer cannot accumulate an unbounded outbound page queue
466
+ // by issuing new reads while refusing to consume prior replies.
467
+ if (client.bufferedAmount + Buffer.byteLength(payload) > 4 * 1024 * 1024) {
468
+ this.releaseSocket(client);
469
+ client.terminate();
470
+ return;
471
+ }
472
+ client.send(payload);
473
+ }
474
+ }
475
+ }
441
476
  /** Send a raw JSON-RPC worker line to ONLY one authenticated device. */
442
477
  sendRawToDevice(deviceId, payload) {
443
478
  for (const client of this.wss?.clients ?? []) {
@@ -0,0 +1,15 @@
1
+ /** Bounded, domain-neutral requests for the trusted browser's recording chooser. */
2
+ export declare const panelVideoLimits: Readonly<{
3
+ maxDurationSeconds: 1200;
4
+ maxBytes: number;
5
+ defaultDurationSeconds: 300;
6
+ defaultBytes: number;
7
+ }>;
8
+ export interface PanelVideoOptions {
9
+ source: "camera" | "screen";
10
+ microphone: boolean;
11
+ systemAudio: boolean;
12
+ maxDurationSeconds: number;
13
+ maxBytes: number;
14
+ }
15
+ export declare function panelVideoOptions(raw: unknown): PanelVideoOptions;
@@ -0,0 +1,35 @@
1
+ /** Bounded, domain-neutral requests for the trusted browser's recording chooser. */
2
+ export const panelVideoLimits = Object.freeze({
3
+ maxDurationSeconds: 1200,
4
+ maxBytes: 200 * 1024 * 1024,
5
+ defaultDurationSeconds: 300,
6
+ defaultBytes: 64 * 1024 * 1024,
7
+ });
8
+ export function panelVideoOptions(raw) {
9
+ const value = raw;
10
+ if (!value ||
11
+ typeof value !== "object" ||
12
+ Array.isArray(value) ||
13
+ Object.keys(value).some((key) => !["source", "microphone", "systemAudio", "maxDurationSeconds", "maxBytes"].includes(key)) ||
14
+ (value.source !== "camera" && value.source !== "screen") ||
15
+ (value.microphone !== undefined && typeof value.microphone !== "boolean") ||
16
+ (value.systemAudio !== undefined && typeof value.systemAudio !== "boolean") ||
17
+ (value.source !== "screen" && value.systemAudio === true))
18
+ throw new Error("视频录制请求无效。");
19
+ const maxDurationSeconds = value.maxDurationSeconds ?? panelVideoLimits.defaultDurationSeconds;
20
+ const maxBytes = value.maxBytes ?? panelVideoLimits.defaultBytes;
21
+ if (!Number.isSafeInteger(maxDurationSeconds) ||
22
+ Number(maxDurationSeconds) < 1 ||
23
+ Number(maxDurationSeconds) > panelVideoLimits.maxDurationSeconds ||
24
+ !Number.isSafeInteger(maxBytes) ||
25
+ Number(maxBytes) < 1 ||
26
+ Number(maxBytes) > panelVideoLimits.maxBytes)
27
+ throw new Error("视频录制限制无效。");
28
+ return {
29
+ source: value.source,
30
+ microphone: value.microphone !== false,
31
+ systemAudio: value.systemAudio === true,
32
+ maxDurationSeconds: Number(maxDurationSeconds),
33
+ maxBytes: Number(maxBytes),
34
+ };
35
+ }
@@ -1,3 +1,4 @@
1
+ import { panelVideoLimits, panelVideoOptions } from "./browser-capture.js";
1
2
  import { panelExecutionGate, PanelExecutionBusyError } from "./execution-gate.js";
2
3
  import { randomBytes, createHash } from "node:crypto";
3
4
  import { constants } from "node:fs";
@@ -43,6 +44,8 @@ const METHODS = [
43
44
  "resources.open",
44
45
  "resources.preview",
45
46
  "resources.recordAudio",
47
+ "resources.recordVideo",
48
+ "resources.recordVideo.capabilities",
46
49
  ...panelToolJobMethods,
47
50
  "credentials.connections.list",
48
51
  "credentials.cookies.listForTask",
@@ -299,7 +302,7 @@ function bridgeScript(id, origin) {
299
302
  const requestId = String(++next);
300
303
  const timer = setTimeout(() => {
301
304
  pending.delete(requestId); reject(new Error("Panel request timed out"));
302
- }, ["tasks.start", "tasks.retry", "tasks.queue.set", "resources.recordAudio"].includes(method) ? 30 * 60 * 1000 : 60000);
305
+ }, ["tasks.start", "tasks.retry", "tasks.queue.set", "resources.recordAudio", "resources.recordVideo"].includes(method) ? 30 * 60 * 1000 : 60000);
303
306
  pending.set(requestId, { resolve, reject, timer });
304
307
  try {
305
308
  parent.postMessage({ type: "codeshell-panel:call", instanceId: id, requestId, method, params }, origin);
@@ -1128,6 +1131,7 @@ export function createPanelRuntime(options) {
1128
1131
  ? {
1129
1132
  methodLimits: {
1130
1133
  "resources.recordAudio": { timeoutMs: 30 * 60 * 1000 },
1134
+ "resources.recordVideo": { timeoutMs: 30 * 60 * 1000 },
1131
1135
  ...(app.permissions.includes("process")
1132
1136
  ? {
1133
1137
  "tasks.start": {
@@ -1326,6 +1330,28 @@ export function createPanelRuntime(options) {
1326
1330
  send: (event, payload) => emit(grant, event, payload),
1327
1331
  };
1328
1332
  if (method.startsWith("resources.")) {
1333
+ if (method === "resources.recordVideo.capabilities") {
1334
+ if (params !== undefined &&
1335
+ (!params ||
1336
+ typeof params !== "object" ||
1337
+ Array.isArray(params) ||
1338
+ Object.keys(params).length))
1339
+ error(400, "视频录制能力请求无效。");
1340
+ // Device/encoder support belongs to the trusted browser, not this server.
1341
+ return {
1342
+ effect: method,
1343
+ maxDurationSeconds: panelVideoLimits.maxDurationSeconds,
1344
+ maxBytes: panelVideoLimits.maxBytes,
1345
+ };
1346
+ }
1347
+ if (method === "resources.recordVideo") {
1348
+ try {
1349
+ return { effect: method, ...panelVideoOptions(params) };
1350
+ }
1351
+ catch {
1352
+ error(400, "视频录制请求或限制无效。");
1353
+ }
1354
+ }
1329
1355
  if (method === "resources.recordAudio") {
1330
1356
  const value = (params ?? {});
1331
1357
  const maxDurationSeconds = value.maxDurationSeconds ?? 300;
@@ -11,7 +11,9 @@ export interface DeviceRelayConnectorOptions {
11
11
  relayOrigin: string;
12
12
  hostId: string;
13
13
  publicOrigin: string;
14
- credential: string;
14
+ credential?: string;
15
+ /** Main-owned rotating credentials, resolved before each control connection. */
16
+ getCredential?: (signal: AbortSignal) => Promise<string>;
15
17
  /** Target is always 127.0.0.1; stopping its Host revokes this capability. */
16
18
  localHost: RelayLocalTarget;
17
19
  /** Additional trusted CA for private installations; TLS verification stays enabled. */
@@ -5,7 +5,8 @@ export function createDeviceRelayConnector(options) {
5
5
  if (!isRelayOrigin(options.relayOrigin) ||
6
6
  !isRelayOrigin(options.publicOrigin) ||
7
7
  !isRelayHostId(options.hostId) ||
8
- !isRelayToken(options.credential) ||
8
+ (!options.getCredential && !isRelayToken(options.credential)) ||
9
+ (options.getCredential !== undefined && typeof options.getCredential !== "function") ||
9
10
  !options.localHost ||
10
11
  !Number.isInteger(options.localHost.port) ||
11
12
  options.localHost.port < 1 ||
@@ -27,6 +28,7 @@ export function createDeviceRelayConnector(options) {
27
28
  let control;
28
29
  let reconnect;
29
30
  let failures = 0;
31
+ let credentialAttempt;
30
32
  const pending = new Set();
31
33
  const streams = new Set();
32
34
  const state = (value) => {
@@ -39,18 +41,62 @@ export function createDeviceRelayConnector(options) {
39
41
  };
40
42
  const track = (done) => {
41
43
  pending.add(done);
42
- void done.finally(() => pending.delete(done));
44
+ void done.finally(() => pending.delete(done)).catch(() => { });
43
45
  };
44
- const connect = (attempt) => {
46
+ const retry = (attempt) => {
47
+ if (!running || generation !== attempt || config.localHost.signal.aborted)
48
+ return;
49
+ const delay = Math.min(30_000, 500 * 2 ** Math.min(failures++, 6));
50
+ reconnect = setTimeout(() => track(connect(attempt)), delay * (0.75 + Math.random() * 0.5));
51
+ };
52
+ const connect = async (attempt) => {
45
53
  if (!running || generation !== attempt || config.localHost.signal.aborted)
46
54
  return;
47
55
  state("connecting");
48
56
  if (!running || generation !== attempt || config.localHost.signal.aborted)
49
57
  return;
58
+ const acquisition = new AbortController();
59
+ credentialAttempt?.abort();
60
+ credentialAttempt = acquisition;
61
+ let credential;
62
+ try {
63
+ credential = config.getCredential
64
+ ? await new Promise((resolve, reject) => {
65
+ const abort = () => reject(new Error("Relay credential acquisition cancelled"));
66
+ acquisition.signal.addEventListener("abort", abort, { once: true });
67
+ Promise.resolve()
68
+ .then(() => config.getCredential(acquisition.signal))
69
+ .then(resolve, reject)
70
+ .finally(() => acquisition.signal.removeEventListener("abort", abort))
71
+ .catch(() => { });
72
+ })
73
+ : config.credential;
74
+ if (!isRelayToken(credential))
75
+ throw new Error("Invalid relay credential");
76
+ }
77
+ catch (error) {
78
+ if (!running || generation !== attempt || acquisition.signal.aborted)
79
+ return;
80
+ if (error?.status === 401 ||
81
+ error?.status === 403) {
82
+ running = false;
83
+ state("unauthorized");
84
+ }
85
+ else {
86
+ state("disconnected");
87
+ retry(attempt);
88
+ }
89
+ return;
90
+ }
91
+ if (!running ||
92
+ generation !== attempt ||
93
+ acquisition.signal.aborted ||
94
+ config.localHost.signal.aborted)
95
+ return;
50
96
  const url = new URL(RELAY_CONNECT_PATH, config.relayOrigin);
51
97
  url.protocol = "wss:";
52
98
  const ws = new WebSocket(url, {
53
- headers: { Authorization: `Bearer ${config.credential}` },
99
+ headers: { Authorization: `Bearer ${credential}` },
54
100
  ca: config.ca,
55
101
  followRedirects: false,
56
102
  perMessageDeflate: false,
@@ -79,8 +125,7 @@ export function createDeviceRelayConnector(options) {
79
125
  state("disconnected");
80
126
  if (!running || generation !== attempt || control || config.localHost.signal.aborted)
81
127
  return resolve();
82
- const delay = Math.min(30_000, 500 * 2 ** Math.min(failures++, 6));
83
- reconnect = setTimeout(() => connect(attempt), delay * (0.75 + Math.random() * 0.5));
128
+ retry(attempt);
84
129
  }
85
130
  resolve();
86
131
  })));
@@ -146,7 +191,7 @@ export function createDeviceRelayConnector(options) {
146
191
  };
147
192
  if (streams.size >= RELAY_MAX_STREAMS)
148
193
  return failed();
149
- const stream = openRelayStream(config, message, failed);
194
+ const stream = openRelayStream({ ...config, credential }, message, failed);
150
195
  owned.set(message.streamId, stream);
151
196
  streams.add(stream);
152
197
  const done = stream.done.then(() => {
@@ -166,7 +211,8 @@ export function createDeviceRelayConnector(options) {
166
211
  config.localHost.signal.addEventListener("abort", revoke, { once: true });
167
212
  running = true;
168
213
  failures = 0;
169
- connect(++generation);
214
+ const acquisition = connect(++generation);
215
+ track(acquisition);
170
216
  },
171
217
  close() {
172
218
  if (closing)
@@ -174,6 +220,8 @@ export function createDeviceRelayConnector(options) {
174
220
  running = false;
175
221
  config.localHost.signal.removeEventListener("abort", revoke);
176
222
  generation++;
223
+ credentialAttempt?.abort();
224
+ credentialAttempt = undefined;
177
225
  clearTimeout(reconnect);
178
226
  reconnect = undefined;
179
227
  control?.terminate();
@@ -5,6 +5,8 @@ export interface RelayLocalStream {
5
5
  done: Promise<void>;
6
6
  }
7
7
  /** One fixed loopback connection. Never accept a target, URL, or port from control messages. */
8
- export declare function openRelayStream(config: DeviceRelayConnectorOptions, message: Extract<RelayControlMessage, {
8
+ export declare function openRelayStream(config: DeviceRelayConnectorOptions & {
9
+ credential: string;
10
+ }, message: Extract<RelayControlMessage, {
9
11
  type: "open";
10
12
  }>, failed: () => void): RelayLocalStream;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cjhyy/code-shell-server",
3
- "version": "0.9.29",
3
+ "version": "0.9.30",
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",
@@ -60,13 +60,13 @@
60
60
  "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\""
61
61
  },
62
62
  "dependencies": {
63
- "@cjhyy/code-shell-capability-coding": "0.9.29",
64
- "@cjhyy/code-shell-core": "0.9.29",
65
- "@cjhyy/code-shell-web": "0.9.29",
63
+ "@cjhyy/code-shell-capability-coding": "0.9.30",
64
+ "@cjhyy/code-shell-core": "0.9.30",
65
+ "@cjhyy/code-shell-web": "0.9.30",
66
66
  "@modelcontextprotocol/sdk": "^1.31.0",
67
67
  "@types/ws": "^8.18.1",
68
68
  "ws": "^8.21.0",
69
- "@cjhyy/code-shell-link": "0.9.29"
69
+ "@cjhyy/code-shell-link": "0.9.30"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">=20.10"