@cjhyy/code-shell-server 0.9.29 → 0.9.31

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.
@@ -113,6 +113,7 @@ export function createLinkHttp(options) {
113
113
  "connectionId",
114
114
  "expectedRevision",
115
115
  "authModeId",
116
+ "resourceUrl",
116
117
  ]);
117
118
  result = await service.startAuthorization(owner, input, input.authModeId);
118
119
  }
@@ -170,6 +171,7 @@ export function createLinkHttp(options) {
170
171
  "connectionId",
171
172
  "expectedRevision",
172
173
  ...(token ? ["token"] : []),
174
+ ...(url.pathname.endsWith("/remote") ? ["resourceUrl"] : []),
173
175
  ]);
174
176
  result = token
175
177
  ? await service.connectToken(owner, input)
@@ -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();
@@ -186,6 +186,14 @@ export function createLinkService(options = {}) {
186
186
  id: meta.linkAccountId,
187
187
  label: meta.linkAccountLabel,
188
188
  resources: meta.linkResourceLabels?.slice(0, 100) ?? [],
189
+ ...(remote && meta.linkResourceGroups
190
+ ? {
191
+ resourceGroups: meta.linkResourceGroups.map((group) => ({
192
+ id: group.id,
193
+ items: group.items.map((item) => ({ id: item.id, label: item.label })),
194
+ })),
195
+ }
196
+ : {}),
189
197
  },
190
198
  capabilityIds: meta.linkCapabilityIds ?? [],
191
199
  verifiedAt: meta.linkLastVerifiedAt,
@@ -664,6 +672,8 @@ export function createLinkService(options = {}) {
664
672
  async function startAuthorization(context, input, rawAuthModeId) {
665
673
  await authorize(context);
666
674
  const authModeId = string(rawAuthModeId, 80);
675
+ if (input?.resourceUrl !== undefined && authModeId !== "remote-link")
676
+ throw new LinkServiceError(400, "invalid_request");
667
677
  if (authModeId === "remote-link")
668
678
  await refreshRemoteCatalog(context);
669
679
  const provider = snapshot().providers.find((candidate) => candidate.id === input?.providerId);
@@ -1029,7 +1039,12 @@ export function createLinkService(options = {}) {
1029
1039
  checkCapacity();
1030
1040
  if (!input ||
1031
1041
  input.methodId !== "remote-link" ||
1032
- !REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input.providerId))
1042
+ !REMOTE_LINK_ADAPTERS.some((adapter) => adapter.id === input.providerId) ||
1043
+ (input.resourceUrl !== undefined &&
1044
+ (input.providerId !== "figma" ||
1045
+ !input.connectionId ||
1046
+ typeof input.resourceUrl !== "string" ||
1047
+ !input.resourceUrl)))
1033
1048
  throw new LinkServiceError(400, "invalid_request");
1034
1049
  const config = options.remoteLink?.();
1035
1050
  if (!config)
@@ -1055,11 +1070,24 @@ export function createLinkService(options = {}) {
1055
1070
  throw new LinkServiceError(409, "conflict");
1056
1071
  }
1057
1072
  const prepared = { id, expected, input: { ...input, label: string(input.label) } };
1073
+ if (input.resourceUrl !== undefined && !expected)
1074
+ throw new LinkServiceError(400, "invalid_request");
1058
1075
  let attempt;
1059
1076
  try {
1077
+ let resourceFiles;
1078
+ if (input.providerId === "figma") {
1079
+ // Recover the old whitelist from Host custody. Browser input can only suggest one new file.
1080
+ const groups = expected?.meta?.linkResourceGroups;
1081
+ if (expected && (!groups || groups.length !== 1 || groups[0]?.id !== "files"))
1082
+ throw new Error("Invalid saved Figma resources");
1083
+ resourceFiles = groups?.[0]?.items.map((item) => item.id) ?? [];
1084
+ if (input.resourceUrl !== undefined)
1085
+ resourceFiles.push(input.resourceUrl);
1086
+ }
1060
1087
  attempt = beginRemoteLinkAuthorization(config, now(), {
1061
1088
  providerId: input.providerId,
1062
1089
  actions: capabilities.actions,
1090
+ ...(resourceFiles !== undefined ? { resourceFiles } : {}),
1063
1091
  });
1064
1092
  }
1065
1093
  catch {
@@ -1123,6 +1151,10 @@ export function createLinkService(options = {}) {
1123
1151
  },
1124
1152
  });
1125
1153
  await check();
1154
+ if (job.attempt.providerId === "figma" &&
1155
+ job.prepared.expected &&
1156
+ credential.meta?.linkAccountId !== job.prepared.expected.meta?.linkAccountId)
1157
+ throw new LinkServiceError(409, "conflict");
1126
1158
  const guarded = {
1127
1159
  ownerId: context.ownerId,
1128
1160
  authorize: async () => {
@@ -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,5 +1,4 @@
1
1
  import { type PanelAppPreview, type PanelAppSourceInput } from "@cjhyy/code-shell-core";
2
- import { getRefCommit } from "@cjhyy/code-shell-core/internal/skills";
3
2
  import type { PanelCompatibility, PanelDiscovery, PanelOperationContext, PanelReview, PanelProjectReview, PanelSnapshot, PanelPackageHistory, PanelPackageRestoreReview } from "./types.js";
4
3
  export declare class PanelManagementError extends Error {
5
4
  readonly status: number;
@@ -23,7 +22,10 @@ export interface PanelManagementOptions {
23
22
  compatibility?: (app: Pick<PanelAppPreview, "permissions" | "agent">) => PanelCompatibility;
24
23
  now?: () => number;
25
24
  /** Network seam only; production still uses the reviewed Core installer. */
26
- resolveCommit?: typeof getRefCommit;
25
+ resolveCommit?: (info: {
26
+ owner: string;
27
+ repo: string;
28
+ }, ref: string) => Promise<string>;
27
29
  }
28
30
  export declare function createPanelManagement(options: PanelManagementOptions): {
29
31
  snapshot: (appId?: string) => Promise<PanelSnapshot>;
@@ -3,9 +3,9 @@ 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, resolvePanelAppBindingPolicy, uninstallPanelApp, } from "@cjhyy/code-shell-core";
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";
7
7
  import { mutateJsonFile } from "@cjhyy/code-shell-core/internal";
8
- import { getRefCommit, parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
8
+ import { parseGithubUrl } from "@cjhyy/code-shell-core/internal/skills";
9
9
  const REVIEW_TTL = 8 * 60_000;
10
10
  const MAX_REVIEWS = 8;
11
11
  const MAX_NETWORK = 2;
@@ -47,7 +47,7 @@ export function publicPanelError(error) {
47
47
  return new PanelManagementError(503, "github_unavailable", "GitHub 暂时不可用,请稍后重试。");
48
48
  if (/(?:ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|UND_ERR_SOCKET)/.test(code) ||
49
49
  /^(?:fetch failed|Failed to fetch)$/i.test(message) ||
50
- /GitHub source download failed/i.test(message))
50
+ /GitHub source (?:download|ref lookup) failed/i.test(message))
51
51
  return new PanelManagementError(502, "github_network", "服务端无法连接 GitHub,请检查网络、DNS 或代理设置后重试。");
52
52
  if (error instanceof PanelAppInstallError)
53
53
  return new PanelManagementError(400, "invalid_source", error.message);
@@ -352,7 +352,9 @@ export function createPanelManagement(options) {
352
352
  const ref = parsed.source.ref ?? "HEAD";
353
353
  const commit = SHA.test(ref)
354
354
  ? ref.toLowerCase()
355
- : await (options.resolveCommit ?? getRefCommit)({ owner: parsed.owner, repo: parsed.repo }, ref);
355
+ : options.resolveCommit
356
+ ? await options.resolveCommit({ owner: parsed.owner, repo: parsed.repo }, ref)
357
+ : await resolveGitHubPanelAppCommit(parsed.source);
356
358
  await guard();
357
359
  if (!SHA.test(commit))
358
360
  throw new PanelManagementError(502, "invalid_source", "GitHub 没有返回有效的提交版本。");
@@ -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.31",
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.31",
64
+ "@cjhyy/code-shell-core": "0.9.31",
65
+ "@cjhyy/code-shell-web": "0.9.31",
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.31"
70
70
  },
71
71
  "engines": {
72
72
  "node": ">=20.10"