@cjhyy/code-shell-server 0.9.51 → 0.10.1

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 (93) hide show
  1. package/dist/account/cli.d.ts +23 -0
  2. package/dist/account/cli.js +249 -0
  3. package/dist/account/cloud-contract.d.ts +39 -0
  4. package/dist/account/cloud-contract.js +134 -0
  5. package/dist/account/cloud-types.d.ts +47 -0
  6. package/dist/account/cloud-types.js +1 -0
  7. package/dist/account/feature-service.d.ts +47 -0
  8. package/dist/account/feature-service.js +184 -0
  9. package/dist/account/http.d.ts +17 -0
  10. package/dist/account/http.js +158 -0
  11. package/dist/account/links.d.ts +13 -0
  12. package/dist/account/links.js +137 -0
  13. package/dist/account/manager.d.ts +96 -0
  14. package/dist/account/manager.js +976 -0
  15. package/dist/account/native-lock.d.ts +6 -0
  16. package/dist/account/native-lock.js +26 -0
  17. package/dist/account/native-store.d.ts +32 -0
  18. package/dist/account/native-store.js +162 -0
  19. package/dist/account/profile.d.ts +4 -0
  20. package/dist/account/profile.js +46 -0
  21. package/dist/account/store.d.ts +34 -0
  22. package/dist/account/store.js +118 -0
  23. package/dist/account/types.d.ts +138 -0
  24. package/dist/account/types.js +2 -0
  25. package/dist/desktop-web/http-api.d.ts +4 -0
  26. package/dist/desktop-web/http-api.js +12 -4
  27. package/dist/environment-identity.d.ts +1 -1
  28. package/dist/environment-identity.js +2 -1
  29. package/dist/headless-worker-env.d.ts +2 -0
  30. package/dist/headless-worker-env.js +15 -0
  31. package/dist/hub/approval-lease.js +6 -1
  32. package/dist/hub/configuration.d.ts +11 -0
  33. package/dist/hub/configuration.js +34 -1
  34. package/dist/hub/model-parameter-metadata.d.ts +26 -0
  35. package/dist/hub/model-parameter-metadata.js +92 -0
  36. package/dist/hub/run-replay.js +1 -1
  37. package/dist/hub/session-management.js +1 -1
  38. package/dist/index.account.d.ts +12 -0
  39. package/dist/index.account.js +12 -0
  40. package/dist/index.mobile-remote.d.ts +2 -0
  41. package/dist/index.mobile-remote.js +1 -0
  42. package/dist/index.worker.d.ts +1 -0
  43. package/dist/index.worker.js +1 -0
  44. package/dist/mobile-remote/account-relay-access.d.ts +59 -0
  45. package/dist/mobile-remote/account-relay-access.js +174 -0
  46. package/dist/mobile-remote/approval-clock.d.ts +3 -0
  47. package/dist/mobile-remote/approval-clock.js +15 -0
  48. package/dist/mobile-remote/codex-room-agent.d.ts +6 -0
  49. package/dist/mobile-remote/codex-room-agent.js +17 -1
  50. package/dist/mobile-remote/mobile-chat-turn.d.ts +4 -0
  51. package/dist/mobile-remote/mobile-chat-turn.js +3 -0
  52. package/dist/mobile-remote/mobile-client-event-validator.js +47 -2
  53. package/dist/mobile-remote/mobile-upload-service.d.ts +9 -0
  54. package/dist/mobile-remote/mobile-upload-service.js +25 -0
  55. package/dist/mobile-remote/remote-host-manager.d.ts +21 -4
  56. package/dist/mobile-remote/remote-host-manager.js +142 -25
  57. package/dist/mobile-remote/resident-agent.d.ts +14 -0
  58. package/dist/mobile-remote/resident-agent.js +38 -11
  59. package/dist/mobile-remote/room-history.d.ts +7 -0
  60. package/dist/mobile-remote/room-history.js +56 -1
  61. package/dist/mobile-remote/room-manager.d.ts +216 -9
  62. package/dist/mobile-remote/room-manager.js +700 -65
  63. package/dist/mobile-remote/room-process-stop.d.ts +15 -0
  64. package/dist/mobile-remote/room-process-stop.js +41 -0
  65. package/dist/mobile-remote/trusted-device-store.d.ts +12 -0
  66. package/dist/mobile-remote/trusted-device-store.js +54 -1
  67. package/dist/mobile-remote/turn-queue.d.ts +183 -0
  68. package/dist/mobile-remote/turn-queue.js +422 -0
  69. package/dist/mobile-remote/types.d.ts +1 -1
  70. package/dist/panels/agent-task-host.js +6 -12
  71. package/dist/panels/agent-task-service.d.ts +2 -0
  72. package/dist/panels/agent-task-service.js +7 -0
  73. package/dist/panels/browser-capture.d.ts +1 -15
  74. package/dist/panels/browser-capture.js +1 -35
  75. package/dist/panels/directory-picker.d.ts +48 -0
  76. package/dist/panels/directory-picker.js +247 -0
  77. package/dist/panels/http.d.ts +15 -15
  78. package/dist/panels/management-http.d.ts +9 -9
  79. package/dist/panels/process-service.d.ts +5 -4
  80. package/dist/panels/process-service.js +54 -16
  81. package/dist/panels/runtime.js +148 -30
  82. package/dist/panels/tool-jobs-storage.d.ts +4 -0
  83. package/dist/panels/tool-jobs-storage.js +111 -34
  84. package/dist/panels/types.d.ts +1 -97
  85. package/dist/remote-relay/connector.d.ts +8 -0
  86. package/dist/remote-relay/connector.js +29 -2
  87. package/dist/remote-relay/protocol.d.ts +21 -0
  88. package/dist/remote-relay/protocol.js +31 -0
  89. package/dist/serve/cli.js +6 -0
  90. package/dist/serve/headless-server.js +24 -19
  91. package/dist/sessions-service.d.ts +4 -0
  92. package/dist/sessions-service.js +14 -1
  93. package/package.json +10 -6
@@ -0,0 +1,15 @@
1
+ /** Desktop host clients that have no IPC peer in a headless worker. */
2
+ const HEADLESS_WORKER_HOST_ENV_KEYS = [
3
+ "CODE_SHELL_REMOTE_LINK_CLIENT_SECRET",
4
+ "CODE_SHELL_OPERATION_BATCH_ACCESS",
5
+ "CODE_SHELL_MODEL_REQUEST_SIGNING",
6
+ "CODE_SHELL_WORKSPACE_ACCESS",
7
+ "CODE_SHELL_HOST_MCP_SERVERS",
8
+ ];
9
+ /** Preserve the caller's environment while disabling Desktop-only host access. */
10
+ export function headlessWorkerEnv(base, extra = {}) {
11
+ const env = { ...base };
12
+ for (const key of HEADLESS_WORKER_HOST_ENV_KEYS)
13
+ env[key] = undefined;
14
+ return { ...env, CODE_SHELL_CREDENTIAL_ACCESS: "local", ...extra };
15
+ }
@@ -9,8 +9,13 @@ export class ApprovalLeases {
9
9
  this.now = now;
10
10
  }
11
11
  add(approval) {
12
- if (this.entries.has(approval.requestId))
12
+ const existing = this.entries.get(approval.requestId);
13
+ if (existing) {
14
+ // A republished request (the user kept a question open) replaces its
15
+ // payload for later snapshots; the holder lease is unchanged.
16
+ existing.approval = approval;
13
17
  return;
18
+ }
14
19
  if (this.entries.size >= 256)
15
20
  throw new Error("too many pending approvals");
16
21
  this.entries.set(approval.requestId, {
@@ -31,6 +31,10 @@ export declare function createHubConfiguration(options: HubConfigurationOptions)
31
31
  text?: string | undefined;
32
32
  };
33
33
  connections: {
34
+ paramValues?: {
35
+ [k: string]: string | number | boolean;
36
+ } | undefined;
37
+ unavailableParameters?: string[] | undefined;
34
38
  id: string;
35
39
  revision: string;
36
40
  catalogId: string;
@@ -46,6 +50,13 @@ export declare function createHubConfiguration(options: HubConfigurationOptions)
46
50
  adapterKind: string;
47
51
  defaultBaseUrl: string;
48
52
  needsKey: boolean;
53
+ modelPresets: {
54
+ parameters: import("./model-parameter-metadata.js").PublicModelParameter[];
55
+ supportsVision?: boolean | undefined;
56
+ maxContextTokens?: number | undefined;
57
+ value: string;
58
+ label: string;
59
+ }[];
49
60
  }[];
50
61
  skills: {
51
62
  disabledReason?: string | undefined;
@@ -4,6 +4,7 @@ import { SettingsManager, invalidateSkillCache, mergePluginMcpServers, scanSkill
4
4
  import { computeEffectiveDisabledLists, getMergedCatalog } from "@cjhyy/code-shell-core/internal";
5
5
  import { withModelTraceContext } from "@cjhyy/code-shell-core/internal";
6
6
  import { probeConfiguredModel } from "@cjhyy/code-shell-host";
7
+ import { publicModelPresets, publicParameterValues, validateParameterPatch, unavailableParameters, } from "./model-parameter-metadata.js";
7
8
  const ROOT = "/api/v1/configuration";
8
9
  const MAX_BODY_BYTES = 64 * 1024;
9
10
  const MAX_SKILL_BYTES = 2 * 1024 * 1024;
@@ -69,6 +70,7 @@ export function createHubConfiguration(options) {
69
70
  protocol: template?.protocol,
70
71
  adapterKind: template?.adapterKind,
71
72
  defaultBaseUrl: template?.defaultBaseUrl,
73
+ modelPresets: template?.modelPresets,
72
74
  },
73
75
  }))
74
76
  .digest("hex");
@@ -131,6 +133,12 @@ export function createHubConfiguration(options) {
131
133
  baseUrl: displayUrl(connection.baseUrl ?? credential?.baseUrl ?? template?.defaultBaseUrl),
132
134
  hasApiKey: Boolean(credential?.apiKey),
133
135
  needsKey: template?.needsKey !== false,
136
+ ...(connection.tag === "text"
137
+ ? {
138
+ paramValues: publicParameterValues(template?.modelPresets?.find((preset) => preset.value === connection.model), connection.paramValues),
139
+ unavailableParameters: unavailableParameters(template?.modelPresets?.find((preset) => preset.value === connection.model), connection.paramValues),
140
+ }
141
+ : {}),
134
142
  };
135
143
  }),
136
144
  catalog: catalog
@@ -141,6 +149,7 @@ export function createHubConfiguration(options) {
141
149
  adapterKind: entry.adapterKind,
142
150
  defaultBaseUrl: displayUrl(entry.defaultBaseUrl),
143
151
  needsKey: entry.needsKey !== false,
152
+ modelPresets: publicModelPresets(entry),
144
153
  })),
145
154
  skills: allSkills.map((skill) => ({
146
155
  name: skill.name,
@@ -200,7 +209,15 @@ export function createHubConfiguration(options) {
200
209
  });
201
210
  }
202
211
  function updateConnection(input) {
203
- knownFields(input, ["id", "catalogId", "model", "baseUrl", "apiKey", "expectedRevision"]);
212
+ knownFields(input, [
213
+ "id",
214
+ "catalogId",
215
+ "model",
216
+ "baseUrl",
217
+ "apiKey",
218
+ "expectedRevision",
219
+ "paramValues",
220
+ ]);
204
221
  const id = boundedString(input.id, "id", 128);
205
222
  const catalogId = boundedString(input.catalogId, "catalogId", 128);
206
223
  const model = boundedString(input.model, "model", 256);
@@ -213,6 +230,21 @@ export function createHubConfiguration(options) {
213
230
  const effective = settingsManager().get();
214
231
  checkConnectionRevision(input, id, effective);
215
232
  const existing = effective.modelConnections.find((entry) => entry.id === id);
233
+ if (input.paramValues !== undefined &&
234
+ (existing ? typeof input.expectedRevision !== "string" : input.expectedRevision !== null))
235
+ throw new HubConfigurationError(409, "请载入最新连接后再保存模型参数。");
236
+ const parameters = input.paramValues === undefined
237
+ ? undefined
238
+ : validateParameterPatch(template.modelPresets?.find((preset) => preset.value === model), input.paramValues);
239
+ if (input.paramValues !== undefined && parameters === undefined)
240
+ throw new HubConfigurationError(400, "模型参数不受支持或超出允许范围,请刷新后检查。");
241
+ const mergedParameters = { ...existing?.paramValues };
242
+ for (const [key, value] of Object.entries(parameters ?? {})) {
243
+ if (value === null)
244
+ delete mergedParameters[key];
245
+ else
246
+ mergedParameters[key] = value;
247
+ }
216
248
  if (existing && (existing.tag !== "text" || existing.catalogId !== catalogId)) {
217
249
  throw new HubConfigurationError(400, "existing connection provider cannot be changed");
218
250
  }
@@ -246,6 +278,7 @@ export function createHubConfiguration(options) {
246
278
  model,
247
279
  ...(credentialId ? { credentialId } : {}),
248
280
  ...(baseUrl !== undefined ? { baseUrl: baseUrl || template.defaultBaseUrl } : {}),
281
+ ...(parameters !== undefined ? { paramValues: mergedParameters } : {}),
249
282
  };
250
283
  current.modelConnections = [
251
284
  ...effective.modelConnections.filter((entry) => entry.id !== id),
@@ -0,0 +1,26 @@
1
+ import type { getMergedCatalog } from "@cjhyy/code-shell-core/internal";
2
+ type Catalog = ReturnType<typeof getMergedCatalog>[number];
3
+ type Preset = NonNullable<Catalog["modelPresets"]>[number];
4
+ export interface PublicModelParameter {
5
+ name: string;
6
+ label: string;
7
+ control: "enum" | "number" | "toggle";
8
+ options?: string[];
9
+ min?: number;
10
+ max?: number;
11
+ default?: string | number | boolean;
12
+ }
13
+ export declare function modelParameters(preset?: Preset): PublicModelParameter[];
14
+ export declare function publicParameterValues(preset: Preset | undefined, values: unknown): {
15
+ [k: string]: string | number | boolean;
16
+ };
17
+ export declare function unavailableParameters(preset: Preset | undefined, values: unknown): string[];
18
+ export declare function validateParameterPatch(preset: Preset | undefined, input: unknown): Record<string, string | number | boolean | null> | undefined;
19
+ export declare function publicModelPresets(entry: Catalog): {
20
+ parameters: PublicModelParameter[];
21
+ supportsVision?: boolean | undefined;
22
+ maxContextTokens?: number | undefined;
23
+ value: string;
24
+ label: string;
25
+ }[];
26
+ export {};
@@ -0,0 +1,92 @@
1
+ /** Deliberately limited to text-generation controls; credential, endpoint,
2
+ * header, arbitrary text and wire-path declarations never reach the browser. */
3
+ const NAMES = new Set([
4
+ "reasoning",
5
+ "reasoning_effort",
6
+ "thinking_type",
7
+ "temperature",
8
+ "top_p",
9
+ "max_tokens",
10
+ ]);
11
+ const FIELDS = {
12
+ reasoning: [
13
+ "reasoning",
14
+ "reasoning_effort",
15
+ "reasoning.effort",
16
+ "thinking.budget_tokens",
17
+ "thinking.type",
18
+ ],
19
+ reasoning_effort: ["reasoning_effort"],
20
+ thinking_type: ["thinking_type", "thinking.type"],
21
+ temperature: ["temperature"],
22
+ top_p: ["top_p"],
23
+ max_tokens: ["max_tokens"],
24
+ };
25
+ export function modelParameters(preset) {
26
+ return (preset?.params ?? []).flatMap((spec) => {
27
+ if (!NAMES.has(spec.name) ||
28
+ preset.params.filter((candidate) => candidate.name === spec.name).length !== 1 ||
29
+ (spec.control !== "enum" && spec.control !== "number" && spec.control !== "toggle") ||
30
+ !FIELDS[spec.name]?.includes(spec.wire?.field ?? spec.name))
31
+ return [];
32
+ const result = {
33
+ name: spec.name,
34
+ label: spec.label ?? spec.name,
35
+ control: spec.control,
36
+ ...(spec.control === "enum" ? { options: [...(spec.options ?? [])] } : {}),
37
+ ...(spec.control === "number" ? { min: spec.min ?? 0, max: spec.max ?? 1_000_000_000 } : {}),
38
+ };
39
+ if (validParameter(result, spec.default))
40
+ result.default = spec.default;
41
+ return [result];
42
+ });
43
+ }
44
+ function validParameter(spec, value) {
45
+ if (spec.control === "enum")
46
+ return typeof value === "string" && value.length <= 256 && !!spec.options?.includes(value);
47
+ if (spec.control === "toggle")
48
+ return typeof value === "boolean";
49
+ if (spec.control !== "number")
50
+ return false;
51
+ return (typeof value === "number" &&
52
+ Number.isFinite(value) &&
53
+ value >= (spec.min ?? 0) &&
54
+ value <= (spec.max ?? 1_000_000_000));
55
+ }
56
+ export function publicParameterValues(preset, values) {
57
+ const source = values && typeof values === "object" && !Array.isArray(values) ? values : {};
58
+ return Object.fromEntries(modelParameters(preset).flatMap((spec) => {
59
+ const value = source[spec.name];
60
+ return validParameter(spec, value) ? [[spec.name, value]] : [];
61
+ }));
62
+ }
63
+ export function unavailableParameters(preset, values) {
64
+ const source = values && typeof values === "object" && !Array.isArray(values) ? values : {};
65
+ return modelParameters(preset)
66
+ .filter((spec) => Object.hasOwn(source, spec.name) &&
67
+ !validParameter(spec, source[spec.name]))
68
+ .map((spec) => spec.name);
69
+ }
70
+ export function validateParameterPatch(preset, input) {
71
+ if (!input || typeof input !== "object" || Array.isArray(input))
72
+ return undefined;
73
+ const specs = modelParameters(preset);
74
+ const patch = Object.entries(input);
75
+ if (patch.length > NAMES.size)
76
+ return undefined;
77
+ if (patch.some(([key, value]) => {
78
+ const spec = specs.find((candidate) => candidate.name === key);
79
+ return !spec || (value !== null && !validParameter(spec, value));
80
+ }))
81
+ return undefined;
82
+ return input;
83
+ }
84
+ export function publicModelPresets(entry) {
85
+ return (entry.modelPresets ?? []).map((preset) => ({
86
+ value: preset.value,
87
+ label: preset.label ?? preset.value,
88
+ ...(preset.maxContextTokens !== undefined ? { maxContextTokens: preset.maxContextTokens } : {}),
89
+ ...(preset.supportsVision !== undefined ? { supportsVision: preset.supportsVision } : {}),
90
+ parameters: modelParameters(preset),
91
+ }));
92
+ }
@@ -1,4 +1,4 @@
1
- import { OutputCoverage } from "@cjhyy/code-shell-web";
1
+ import { OutputCoverage } from "@cjhyy/code-shell-web/output-coverage";
2
2
  import { randomUUID } from "node:crypto";
3
3
  /**
4
4
  * A refresh can happen before the worker persists its current assistant text.
@@ -2,7 +2,7 @@ import { SessionFileError, readSessionFileSync, sessionListStorage, } from "@cjh
2
2
  import { listSessionEntries } from "@cjhyy/code-shell-core/internal";
3
3
  import { join, resolve } from "node:path";
4
4
  import { SessionManager } from "@cjhyy/code-shell-core";
5
- import { replayTranscript, transcriptUserDisplay } from "@cjhyy/code-shell-web";
5
+ import { replayTranscript, transcriptUserDisplay } from "@cjhyy/code-shell-web/transcript-replay";
6
6
  import { createSessionTitlesStore, SessionTitleConflictError } from "../session-titles-store.js";
7
7
  import { hubJson } from "./uploads.js";
8
8
  const ROOT = "/api/v1/sessions";
@@ -0,0 +1,12 @@
1
+ /** Optional native account authority. No Electron or renderer dependencies. */
2
+ export * from "./account/types.js";
3
+ export * from "./account/manager.js";
4
+ export * from "./account/http.js";
5
+ export * from "./account/store.js";
6
+ export * from "./account/links.js";
7
+ export * from "./account/profile.js";
8
+ export * from "./account/cloud-contract.js";
9
+ export * from "./account/cloud-types.js";
10
+ export * from "./account/feature-service.js";
11
+ export * from "./account/native-store.js";
12
+ export * from "./account/cli.js";
@@ -0,0 +1,12 @@
1
+ /** Optional native account authority. No Electron or renderer dependencies. */
2
+ export * from "./account/types.js";
3
+ export * from "./account/manager.js";
4
+ export * from "./account/http.js";
5
+ export * from "./account/store.js";
6
+ export * from "./account/links.js";
7
+ export * from "./account/profile.js";
8
+ export * from "./account/cloud-contract.js";
9
+ export * from "./account/cloud-types.js";
10
+ export * from "./account/feature-service.js";
11
+ export * from "./account/native-store.js";
12
+ export * from "./account/cli.js";
@@ -1,5 +1,6 @@
1
1
  /** Mobile HTTP/WebSocket transport, rooms, uploads, pairing, and tunnels. */
2
2
  export * from "./mobile-remote/access-passcode.js";
3
+ export type { AccountRelayAccessProvider, AccountRelayBinding, AccountRelayProject, } from "./mobile-remote/account-relay-access.js";
3
4
  export * from "./mobile-remote/cloudflared-binary.js";
4
5
  export * from "./mobile-remote/codex-parse.js";
5
6
  export * from "./mobile-remote/codex-room-agent.js";
@@ -17,5 +18,6 @@ export * from "./mobile-remote/resident-agent.js";
17
18
  export * from "./mobile-remote/room-manager.js";
18
19
  export * from "./mobile-remote/trusted-device-store.js";
19
20
  export * from "./mobile-remote/tunnel-manager.js";
21
+ export * from "./mobile-remote/turn-queue.js";
20
22
  export * from "./mobile-remote/types.js";
21
23
  export * from "./mobile-remote/viewer-identity.js";
@@ -17,5 +17,6 @@ export * from "./mobile-remote/resident-agent.js";
17
17
  export * from "./mobile-remote/room-manager.js";
18
18
  export * from "./mobile-remote/trusted-device-store.js";
19
19
  export * from "./mobile-remote/tunnel-manager.js";
20
+ export * from "./mobile-remote/turn-queue.js";
20
21
  export * from "./mobile-remote/types.js";
21
22
  export * from "./mobile-remote/viewer-identity.js";
@@ -1,2 +1,3 @@
1
1
  /** Transport-agnostic stdio worker bridge for Node hosts. */
2
2
  export * from "./worker-bridge-core.js";
3
+ export { headlessWorkerEnv } from "./headless-worker-env.js";
@@ -1,2 +1,3 @@
1
1
  /** Transport-agnostic stdio worker bridge for Node hosts. */
2
2
  export * from "./worker-bridge-core.js";
3
+ export { headlessWorkerEnv } from "./headless-worker-env.js";
@@ -0,0 +1,59 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { TrustedDeviceStore } from "./trusted-device-store.js";
3
+ export interface AccountRelayBinding {
4
+ authorizationId: string;
5
+ accountId: string;
6
+ rootSessionId: string;
7
+ hostId: string;
8
+ environmentId: string;
9
+ leaseId: string;
10
+ expiresAt: number;
11
+ }
12
+ export interface AccountRelayProject {
13
+ id: string;
14
+ name: string;
15
+ path: string;
16
+ }
17
+ /** Main owns device credentials and redeems only against its configured directory. */
18
+ export interface AccountRelayAccessProvider {
19
+ hostId: string;
20
+ environmentId: string;
21
+ accountId?: string;
22
+ redeem: (handoff: string) => Promise<AccountRelayBinding>;
23
+ listProjects: () => Promise<AccountRelayProject[]>;
24
+ }
25
+ export interface CapturedAccountRelayAccess {
26
+ deviceId: string;
27
+ isAuthorized: () => boolean;
28
+ }
29
+ /** Independent entrance gate; an account credential is never a Host credential. */
30
+ export declare class AccountRelayAccessGate {
31
+ private readonly devices;
32
+ private readonly onRevoke;
33
+ private provider?;
34
+ private readonly grants;
35
+ private readonly consumed;
36
+ private readonly pending;
37
+ private generation;
38
+ constructor(devices: TrustedDeviceStore, onRevoke: (deviceId: string) => void);
39
+ setProvider(provider?: AccountRelayAccessProvider): void;
40
+ get available(): boolean;
41
+ clear(): void;
42
+ revokeDevice(deviceId: string): boolean;
43
+ private retire;
44
+ exchange(handoff: string): Promise<{
45
+ hostAccessToken: string;
46
+ deviceId: string;
47
+ secretHash: string;
48
+ expiresAt: number;
49
+ hostId: string;
50
+ environmentId: string;
51
+ }>;
52
+ capture(request: IncomingMessage): CapturedAccountRelayAccess | undefined;
53
+ projects(access: CapturedAccountRelayAccess): Promise<{
54
+ hostId: string;
55
+ environmentId: string;
56
+ projects: AccountRelayProject[];
57
+ }>;
58
+ }
59
+ export declare function readRelayHandoff(request: IncomingMessage): Promise<string>;
@@ -0,0 +1,174 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ /** Independent entrance gate; an account credential is never a Host credential. */
3
+ export class AccountRelayAccessGate {
4
+ devices;
5
+ onRevoke;
6
+ provider;
7
+ grants = new Map();
8
+ consumed = new Map();
9
+ pending = new Set();
10
+ generation = 0;
11
+ constructor(devices, onRevoke) {
12
+ this.devices = devices;
13
+ this.onRevoke = onRevoke;
14
+ }
15
+ setProvider(provider) {
16
+ this.clear();
17
+ this.provider = provider;
18
+ }
19
+ get available() {
20
+ return !!this.provider;
21
+ }
22
+ clear() {
23
+ this.generation++;
24
+ for (const token of this.grants.keys())
25
+ this.retire(token);
26
+ this.consumed.clear();
27
+ this.pending.clear();
28
+ }
29
+ revokeDevice(deviceId) {
30
+ let revoked = false;
31
+ for (const [token, grant] of this.grants)
32
+ if (grant.deviceId === deviceId) {
33
+ this.retire(token);
34
+ revoked = true;
35
+ }
36
+ return revoked;
37
+ }
38
+ retire(token) {
39
+ const grant = this.grants.get(token);
40
+ if (!grant)
41
+ return;
42
+ this.grants.delete(token);
43
+ clearTimeout(grant.timer);
44
+ this.devices.removeEphemeralDevice(grant.deviceId);
45
+ this.onRevoke(grant.deviceId);
46
+ }
47
+ async exchange(handoff) {
48
+ const provider = this.provider;
49
+ const generation = this.generation;
50
+ const digest = createHash("sha256").update(handoff).digest("hex");
51
+ for (const [key, expiresAt] of this.consumed)
52
+ if (expiresAt <= Date.now())
53
+ this.consumed.delete(key);
54
+ if (!provider ||
55
+ this.grants.size + this.pending.size >= 128 ||
56
+ this.pending.has(digest) ||
57
+ this.consumed.has(digest))
58
+ throw new Error("Account relay handoff is unavailable");
59
+ this.pending.add(digest);
60
+ try {
61
+ const binding = await provider.redeem(handoff);
62
+ const now = Date.now();
63
+ if (this.provider !== provider ||
64
+ this.generation !== generation ||
65
+ !binding ||
66
+ typeof binding !== "object" ||
67
+ ![
68
+ binding.authorizationId,
69
+ binding.accountId,
70
+ binding.rootSessionId,
71
+ binding.hostId,
72
+ binding.environmentId,
73
+ binding.leaseId,
74
+ ].every((value) => typeof value === "string" &&
75
+ value.length > 0 &&
76
+ value.length <= 512 &&
77
+ !value.includes("\0")) ||
78
+ binding.hostId !== provider.hostId ||
79
+ binding.environmentId !== provider.environmentId ||
80
+ (provider.accountId !== undefined && binding.accountId !== provider.accountId) ||
81
+ !Number.isSafeInteger(binding.expiresAt) ||
82
+ binding.expiresAt <= now ||
83
+ binding.expiresAt > now + 10 * 60_000)
84
+ throw new Error("Account relay handoff target is invalid");
85
+ this.consumed.set(digest, binding.expiresAt);
86
+ const hostAccessToken = randomBytes(32).toString("base64url");
87
+ const secretHash = randomBytes(32).toString("base64url");
88
+ const device = this.devices.addEphemeralDevice({
89
+ name: "Account device session",
90
+ secretHash,
91
+ expiresAt: binding.expiresAt,
92
+ });
93
+ const timer = setTimeout(() => this.retire(hostAccessToken), binding.expiresAt - now);
94
+ timer.unref();
95
+ this.grants.set(hostAccessToken, {
96
+ provider,
97
+ deviceId: device.id,
98
+ expiresAt: binding.expiresAt,
99
+ timer,
100
+ });
101
+ return {
102
+ hostAccessToken,
103
+ deviceId: device.id,
104
+ secretHash,
105
+ expiresAt: binding.expiresAt,
106
+ hostId: binding.hostId,
107
+ environmentId: binding.environmentId,
108
+ };
109
+ }
110
+ finally {
111
+ // A retired generation cannot release a newer redemption of the same handoff.
112
+ if (this.generation === generation)
113
+ this.pending.delete(digest);
114
+ }
115
+ }
116
+ capture(request) {
117
+ const token = request.headers["x-codeshell-host-access"];
118
+ if (typeof token !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(token))
119
+ return;
120
+ const grant = this.grants.get(token);
121
+ if (!grant)
122
+ return;
123
+ const isAuthorized = () => this.provider === grant.provider &&
124
+ this.grants.get(token) === grant &&
125
+ this.devices.isEphemeralDevice(grant.deviceId) &&
126
+ grant.expiresAt > Date.now();
127
+ if (!isAuthorized()) {
128
+ this.retire(token);
129
+ return;
130
+ }
131
+ return { deviceId: grant.deviceId, isAuthorized };
132
+ }
133
+ async projects(access) {
134
+ const provider = this.provider;
135
+ if (!provider || !access.isAuthorized())
136
+ throw new Error("Account relay access expired");
137
+ const projects = await provider.listProjects();
138
+ if (this.provider !== provider || !access.isAuthorized())
139
+ throw new Error("Account relay access expired");
140
+ return { hostId: provider.hostId, environmentId: provider.environmentId, projects };
141
+ }
142
+ }
143
+ export async function readRelayHandoff(request) {
144
+ if (request.headers["content-type"]?.split(";", 1)[0]?.trim() !== "application/json")
145
+ throw new Error("Invalid relay handoff");
146
+ const declared = Number(request.headers["content-length"] ?? 0);
147
+ if (!Number.isFinite(declared) || declared > 8192)
148
+ throw new Error("Invalid relay handoff");
149
+ const chunks = [];
150
+ let bytes = 0;
151
+ const timeout = setTimeout(() => request.destroy(), 10_000);
152
+ timeout.unref();
153
+ try {
154
+ for await (const chunk of request) {
155
+ bytes += Buffer.byteLength(chunk);
156
+ if (bytes > 8192)
157
+ throw new Error("Invalid relay handoff");
158
+ chunks.push(Buffer.from(chunk));
159
+ }
160
+ const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
161
+ if (!input ||
162
+ typeof input !== "object" ||
163
+ Array.isArray(input) ||
164
+ Object.keys(input).length !== 1 ||
165
+ !("handoff" in input) ||
166
+ typeof input.handoff !== "string" ||
167
+ !/^[A-Za-z0-9_-]{32,4096}$/.test(input.handoff))
168
+ throw new Error("Invalid relay handoff");
169
+ return input.handoff;
170
+ }
171
+ finally {
172
+ clearTimeout(timeout);
173
+ }
174
+ }
@@ -0,0 +1,3 @@
1
+ /** Timestamp at delivery, including reconnect replay, so a phone never compares
2
+ * a worker deadline directly with its own potentially skewed wall clock. */
3
+ export declare function stampApprovalClock(line: string, now?: number): string;
@@ -0,0 +1,15 @@
1
+ /** Timestamp at delivery, including reconnect replay, so a phone never compares
2
+ * a worker deadline directly with its own potentially skewed wall clock. */
3
+ export function stampApprovalClock(line, now = Date.now()) {
4
+ if (!line.includes("agent/approvalRequest"))
5
+ return line;
6
+ try {
7
+ const message = JSON.parse(line);
8
+ if (message?.method !== "agent/approvalRequest" || !Number.isFinite(message.params?.expiresAt))
9
+ return line;
10
+ return JSON.stringify({ ...message, serverTime: now });
11
+ }
12
+ catch {
13
+ return line;
14
+ }
15
+ }
@@ -74,6 +74,8 @@ export interface CodexRoomAgentOptions {
74
74
  export declare class CodexRoomAgent implements RoomAgent {
75
75
  private readonly opts;
76
76
  private child?;
77
+ /** Last turn process, kept so stopAndWait() can prove it exited. */
78
+ private lastChild?;
77
79
  private threadId?;
78
80
  private started;
79
81
  constructor(opts: CodexRoomAgentOptions);
@@ -84,5 +86,9 @@ export declare class CodexRoomAgent implements RoomAgent {
84
86
  * process is currently alive (turns are ephemeral). */
85
87
  isRunning(): boolean;
86
88
  stop(): void;
89
+ /** A turn's `codex exec` child may still be alive after turn_end was parsed. */
90
+ isBusy(): boolean;
91
+ /** Stop, then resolve true only once the last turn process has exited. */
92
+ stopAndWait(timeoutMs: number): Promise<boolean>;
87
93
  }
88
94
  export {};
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { releaseStdioAfterExit, stopRoomProcess } from "./room-process-stop.js";
2
+ import { releaseStdioAfterExit, stopRoomProcess, stopRoomProcessAndWait, } 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";
@@ -85,6 +85,8 @@ export function sealEventOnExit(opts) {
85
85
  export class CodexRoomAgent {
86
86
  opts;
87
87
  child;
88
+ /** Last turn process, kept so stopAndWait() can prove it exited. */
89
+ lastChild;
88
90
  threadId;
89
91
  started = false;
90
92
  constructor(opts) {
@@ -109,6 +111,7 @@ export class CodexRoomAgent {
109
111
  stdio: ["pipe", "pipe", "pipe"],
110
112
  });
111
113
  this.child = child;
114
+ this.lastChild = child;
112
115
  // Settle on `close` so trailing output is read, without waiting on grandchildren.
113
116
  releaseStdioAfterExit(child);
114
117
  if (child.stdout) {
@@ -210,4 +213,17 @@ export class CodexRoomAgent {
210
213
  if (child)
211
214
  stopRoomProcess(child);
212
215
  }
216
+ /** A turn's `codex exec` child may still be alive after turn_end was parsed. */
217
+ isBusy() {
218
+ return Boolean(this.child);
219
+ }
220
+ /** Stop, then resolve true only once the last turn process has exited. */
221
+ stopAndWait(timeoutMs) {
222
+ this.started = false;
223
+ const child = this.child ?? this.lastChild;
224
+ this.child = undefined;
225
+ if (!child)
226
+ return Promise.resolve(true);
227
+ return stopRoomProcessAndWait(child, timeoutMs);
228
+ }
213
229
  }
@@ -14,6 +14,10 @@ export interface DispatchMobileChatTurnInput {
14
14
  attachments?: MobileImageAttachment[];
15
15
  clientMessageId?: string;
16
16
  modelTraceSource?: string;
17
+ /** Host-resolved per-run model pool key; never a global model setting. */
18
+ model?: string;
19
+ configuredModel?: import("@cjhyy/code-shell-core/internal").ConfiguredTextModelIntent;
20
+ workspaceProfile?: string;
17
21
  permissionMode?: PermissionMode;
18
22
  runId: string;
19
23
  bridge: MobileRunBridge;