@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-D_t6sCWs.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,585 @@
1
+ import { createPrivateKey, createPublicKey, randomUUID, sign, verify } from "node:crypto";
2
+ import { arch, hostname, platform } from "node:os";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join } from "node:path";
5
+ import { atomicWriteFile } from "../config/atomic-write";
6
+ import { getConfigDir } from "../config/paths";
7
+ import { workspaceSecretFileExists, workspaceSecretPermissions, type WorkspaceSecretPermissions } from "./workspace-secret-store";
8
+ import {
9
+ generateRemoteControlIdentityKeyPair,
10
+ type RemoteControlIdentityKeyPair,
11
+ } from "./crypto";
12
+ import { RemoteWorkspaceExecutor } from "./workspace-executor";
13
+ import { RemoteWorkspaceExecutorAgentConnection } from "./workspace-agent-connection";
14
+ import {
15
+ createPlatformRemoteWorkspaceCommandRunner,
16
+ discoverRemoteWorkspaceNativeHelper,
17
+ parseRemoteWorkspaceNativeHelperDescriptor,
18
+ pinRemoteWorkspaceNativeHelper,
19
+ type RemoteWorkspaceNativeHelperDescriptor,
20
+ } from "./workspace-command-runner";
21
+ import type { RemoteWorkspaceCommandRunner } from "./workspace-executor";
22
+ import {
23
+ REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION,
24
+ serializeRemoteWorkspaceAgentMessage,
25
+ } from "./workspace-agent-protocol";
26
+ import {
27
+ parseRemoteWorkspaceCapabilities,
28
+ type RemoteWorkspaceCapability,
29
+ } from "./workspace-tools";
30
+
31
+ export const REMOTE_WORKSPACE_DEVICE_STATE_VERSION = 1 as const;
32
+ const DEVICE_TOKEN_PATTERN = /^ocxrw_[A-Za-z0-9_-]{43}$/;
33
+ const MAX_PAIR_RESPONSE_BYTES = 64 * 1024;
34
+ const MAX_DEVICE_STATE_BYTES = 1024 * 1024;
35
+ const PAIR_TIMEOUT_MS = 15_000;
36
+
37
+ export interface RemoteWorkspaceDeviceRoot {
38
+ id: string;
39
+ label: string;
40
+ path: string;
41
+ }
42
+
43
+ export interface RemoteWorkspaceDeviceState {
44
+ version: typeof REMOTE_WORKSPACE_DEVICE_STATE_VERSION;
45
+ hubUrl: string;
46
+ agentUrl: string;
47
+ deviceId: string;
48
+ deviceName: string;
49
+ devicePlatform: string;
50
+ capabilities: RemoteWorkspaceCapability[];
51
+ deviceToken: string;
52
+ deviceIdentity: RemoteControlIdentityKeyPair;
53
+ hubPublicKey: string;
54
+ roots: RemoteWorkspaceDeviceRoot[];
55
+ toolchainRoots: string[];
56
+ nativeHelper?: RemoteWorkspaceNativeHelperDescriptor;
57
+ }
58
+
59
+ export interface RemoteWorkspaceDeviceStateStore {
60
+ load(): RemoteWorkspaceDeviceState | null;
61
+ save(state: RemoteWorkspaceDeviceState): void;
62
+ }
63
+
64
+ export interface PairRemoteWorkspaceDeviceOptions {
65
+ hubUrl: string;
66
+ pairingCode: string;
67
+ name?: string;
68
+ roots: Array<{ path: string; label?: string }>;
69
+ fetchImpl?: typeof fetch;
70
+ store?: RemoteWorkspaceDeviceStateStore;
71
+ devicePlatform?: string;
72
+ capabilities?: RemoteWorkspaceCapability[];
73
+ toolchainRoots?: string[];
74
+ nativeHelperPath?: string;
75
+ }
76
+
77
+ export interface RemoteWorkspaceWebSocketLike {
78
+ readyState: number;
79
+ send(value: string): void;
80
+ close(code?: number, reason?: string): void;
81
+ addEventListener(type: "open" | "close" | "error" | "message", listener: (event: Event | MessageEvent) => void): void;
82
+ }
83
+
84
+ export type RemoteWorkspaceWebSocketFactory = (
85
+ url: string,
86
+ headers: Record<string, string>,
87
+ ) => RemoteWorkspaceWebSocketLike;
88
+
89
+ function boundedText(value: unknown, label: string, max: number): string {
90
+ if (typeof value !== "string") throw new Error(`invalid remote workspace ${label}`);
91
+ const normalized = value.trim();
92
+ if (normalized.length < 1 || normalized.length > max || /[\x00-\x1f\x7f]/.test(normalized)) {
93
+ throw new Error(`invalid remote workspace ${label}`);
94
+ }
95
+ return normalized;
96
+ }
97
+
98
+ function uuid(value: unknown, label: string): string {
99
+ const text = boundedText(value, label, 64);
100
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text)) {
101
+ throw new Error(`invalid remote workspace ${label}`);
102
+ }
103
+ return text;
104
+ }
105
+
106
+ function publicKey(value: unknown, label: string): string {
107
+ const encoded = boundedText(value, label, 1024);
108
+ if (!/^[A-Za-z0-9_-]+$/.test(encoded)) throw new Error(`invalid remote workspace ${label}`);
109
+ const key = createPublicKey({ key: Buffer.from(encoded, "base64url"), type: "spki", format: "der" });
110
+ if (key.asymmetricKeyType !== "ed25519") throw new Error(`remote workspace ${label} must use Ed25519`);
111
+ return encoded;
112
+ }
113
+
114
+ function identity(value: unknown): RemoteControlIdentityKeyPair {
115
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device identity");
116
+ const raw = value as Record<string, unknown>;
117
+ const pub = publicKey(raw.publicKey, "device public key");
118
+ const priv = boundedText(raw.privateKey, "device private key", 2048);
119
+ const privateKey = createPrivateKey({ key: Buffer.from(priv, "base64url"), type: "pkcs8", format: "der" });
120
+ if (privateKey.asymmetricKeyType !== "ed25519") throw new Error("remote workspace device key must use Ed25519");
121
+ const challenge = Buffer.from("opencodex remote workspace device identity v1", "utf8");
122
+ if (!verify(
123
+ null,
124
+ challenge,
125
+ createPublicKey({ key: Buffer.from(pub, "base64url"), type: "spki", format: "der" }),
126
+ sign(null, challenge, privateKey),
127
+ )) throw new Error("remote workspace device identity key pair does not match");
128
+ return { publicKey: pub, privateKey: priv };
129
+ }
130
+
131
+ export function normalizeRemoteWorkspaceHubUrl(value: string): string {
132
+ const url = new URL(value);
133
+ const local = (url.hostname === "127.0.0.1" || url.hostname === "localhost") && url.protocol === "http:";
134
+ if (url.protocol !== "https:" && !local) throw new Error("remote workspace hub must use HTTPS");
135
+ if (url.username || url.password || url.search || url.hash) throw new Error("remote workspace hub URL must not contain credentials or fragments");
136
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
137
+ return url.toString().replace(/\/$/, "");
138
+ }
139
+
140
+ function agentUrlForHub(hubUrl: string): string {
141
+ const url = new URL("/remote-workspace/agent", `${hubUrl}/`);
142
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
143
+ return url.toString();
144
+ }
145
+
146
+ function validateRootInputs(values: Array<{ path: string; label?: string }>): RemoteWorkspaceDeviceRoot[] {
147
+ if (values.length < 1 || values.length > 32) throw new Error("remote workspace device needs one to 32 roots");
148
+ const paths = new Set<string>();
149
+ const labels = new Set<string>();
150
+ return values.map(value => {
151
+ if (!isAbsolute(value.path) || value.path.includes("\0")) throw new Error("remote workspace root must be an absolute path");
152
+ const metadata = lstatSync(value.path);
153
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("remote workspace root must be a real directory");
154
+ const path = realpathSync(value.path);
155
+ const label = boundedText(value.label ?? basename(path), "root label", 80);
156
+ const folded = label.toLocaleLowerCase("en-US");
157
+ if (paths.has(path) || labels.has(folded)) throw new Error("duplicate remote workspace root");
158
+ paths.add(path);
159
+ labels.add(folded);
160
+ return { id: randomUUID(), label, path };
161
+ });
162
+ }
163
+
164
+ function parseRoots(value: unknown): RemoteWorkspaceDeviceRoot[] {
165
+ if (!Array.isArray(value) || value.length < 1 || value.length > 32) throw new Error("invalid remote workspace device roots");
166
+ const paths = new Set<string>();
167
+ const ids = new Set<string>();
168
+ return value.map(item => {
169
+ if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("invalid remote workspace device root");
170
+ const raw = item as Record<string, unknown>;
171
+ const id = uuid(raw.id, "root ID");
172
+ const label = boundedText(raw.label, "root label", 80);
173
+ const path = boundedText(raw.path, "root path", 4096);
174
+ if (!isAbsolute(path) || ids.has(id) || paths.has(path)) throw new Error("invalid remote workspace device root");
175
+ ids.add(id);
176
+ paths.add(path);
177
+ return { id, label, path };
178
+ });
179
+ }
180
+
181
+ function validateToolchainRoots(value: unknown): string[] {
182
+ if (value === undefined) return [];
183
+ if (!Array.isArray(value) || value.length > 16) throw new Error("invalid remote workspace toolchain roots");
184
+ const paths = new Set<string>();
185
+ for (const candidate of value) {
186
+ if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) {
187
+ throw new Error("remote workspace toolchain root must be an absolute directory");
188
+ }
189
+ const metadata = lstatSync(candidate);
190
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
191
+ throw new Error("remote workspace toolchain root must be a real directory");
192
+ }
193
+ paths.add(realpathSync(candidate));
194
+ }
195
+ return [...paths];
196
+ }
197
+
198
+ export function parseRemoteWorkspaceDeviceState(value: unknown): RemoteWorkspaceDeviceState {
199
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote workspace device state");
200
+ const raw = value as Record<string, unknown>;
201
+ if (raw.version !== REMOTE_WORKSPACE_DEVICE_STATE_VERSION) throw new Error("unsupported remote workspace device state");
202
+ const hubUrl = normalizeRemoteWorkspaceHubUrl(boundedText(raw.hubUrl, "hub URL", 2048));
203
+ const agentUrl = boundedText(raw.agentUrl, "agent URL", 2048);
204
+ if (agentUrl !== agentUrlForHub(hubUrl)) throw new Error("remote workspace agent URL does not match its hub");
205
+ const deviceToken = boundedText(raw.deviceToken, "device token", 128);
206
+ if (!DEVICE_TOKEN_PATTERN.test(deviceToken)) throw new Error("invalid remote workspace device token");
207
+ return {
208
+ version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION,
209
+ hubUrl,
210
+ agentUrl,
211
+ deviceId: uuid(raw.deviceId, "device ID"),
212
+ deviceName: boundedText(raw.deviceName, "device name", 80),
213
+ devicePlatform: boundedText(raw.devicePlatform, "device platform", 80),
214
+ capabilities: parseRemoteWorkspaceCapabilities(raw.capabilities),
215
+ deviceToken,
216
+ deviceIdentity: identity(raw.deviceIdentity),
217
+ hubPublicKey: publicKey(raw.hubPublicKey, "hub public key"),
218
+ roots: parseRoots(raw.roots),
219
+ toolchainRoots: validateToolchainRoots(raw.toolchainRoots),
220
+ ...(raw.nativeHelper === undefined
221
+ ? {}
222
+ : { nativeHelper: parseRemoteWorkspaceNativeHelperDescriptor(raw.nativeHelper) }),
223
+ };
224
+ }
225
+
226
+ export class RemoteWorkspaceDeviceFileStore implements RemoteWorkspaceDeviceStateStore {
227
+ constructor(
228
+ private readonly path = join(getConfigDir(), "remote-workspace-device.json"),
229
+ private readonly permissions: WorkspaceSecretPermissions = workspaceSecretPermissions,
230
+ ) {}
231
+
232
+ load(): RemoteWorkspaceDeviceState | null {
233
+ if (!workspaceSecretFileExists(this.path)) return null;
234
+ this.permissions.prepareDirectory(dirname(this.path));
235
+ this.permissions.hardenFile(this.path);
236
+ const metadata = statSync(this.path);
237
+ if (!metadata.isFile() || metadata.size > MAX_DEVICE_STATE_BYTES) {
238
+ throw new Error("remote workspace device state is too large");
239
+ }
240
+ return parseRemoteWorkspaceDeviceState(JSON.parse(readFileSync(this.path, "utf8")));
241
+ }
242
+
243
+ save(state: RemoteWorkspaceDeviceState): void {
244
+ this.permissions.prepareDirectory(dirname(this.path));
245
+ if (workspaceSecretFileExists(this.path)) this.permissions.hardenFile(this.path);
246
+ atomicWriteFile(this.path, `${JSON.stringify(parseRemoteWorkspaceDeviceState(state), null, 2)}\n`);
247
+ }
248
+ }
249
+
250
+ async function boundedJson(response: Response): Promise<unknown> {
251
+ const declared = Number(response.headers.get("content-length") ?? "0");
252
+ if (Number.isFinite(declared) && declared > MAX_PAIR_RESPONSE_BYTES) throw new Error("remote workspace hub response is too large");
253
+ const reader = response.body?.getReader();
254
+ const chunks: Uint8Array[] = [];
255
+ let total = 0;
256
+ if (reader) {
257
+ try {
258
+ while (true) {
259
+ const next = await reader.read();
260
+ if (next.done) break;
261
+ total += next.value.byteLength;
262
+ if (total > MAX_PAIR_RESPONSE_BYTES) {
263
+ await reader.cancel("remote workspace hub response is too large").catch(() => {});
264
+ throw new Error("remote workspace hub response is too large");
265
+ }
266
+ chunks.push(next.value);
267
+ }
268
+ } finally {
269
+ reader.releaseLock();
270
+ }
271
+ }
272
+ const body = new Uint8Array(total);
273
+ let offset = 0;
274
+ for (const chunk of chunks) {
275
+ body.set(chunk, offset);
276
+ offset += chunk.byteLength;
277
+ }
278
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
279
+ try { return text ? JSON.parse(text) : {}; }
280
+ catch { throw new Error(`remote workspace hub returned HTTP ${response.status}`); }
281
+ }
282
+
283
+ export async function pairRemoteWorkspaceDevice(options: PairRemoteWorkspaceDeviceOptions): Promise<RemoteWorkspaceDeviceState> {
284
+ const hubUrl = normalizeRemoteWorkspaceHubUrl(options.hubUrl);
285
+ const roots = validateRootInputs(options.roots);
286
+ const deviceName = boundedText(options.name ?? hostname(), "device name", 80);
287
+ const devicePlatform = boundedText(options.devicePlatform ?? `${platform()}-${arch()}`, "device platform", 80);
288
+ const toolchainRoots = validateToolchainRoots(options.toolchainRoots);
289
+ const nativeHelper = options.nativeHelperPath
290
+ ? pinRemoteWorkspaceNativeHelper(options.nativeHelperPath)
291
+ : discoverRemoteWorkspaceNativeHelper();
292
+ const commandRunner = createPlatformRemoteWorkspaceCommandRunner({
293
+ linux: { toolchainRoots, writableRoots: roots.map(root => root.path) },
294
+ ...(nativeHelper ? { native: {
295
+ helper: nativeHelper,
296
+ toolchainRoots,
297
+ writableRoots: roots.map(root => root.path),
298
+ } } : {}),
299
+ });
300
+ const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, options.capabilities);
301
+ const deviceIdentity = generateRemoteControlIdentityKeyPair();
302
+ const response = await (options.fetchImpl ?? fetch)(new URL("/remote-workspace/pair", `${hubUrl}/`), {
303
+ method: "POST",
304
+ redirect: "error",
305
+ signal: AbortSignal.timeout(PAIR_TIMEOUT_MS),
306
+ headers: { "content-type": "application/json", accept: "application/json" },
307
+ body: JSON.stringify({
308
+ code: options.pairingCode,
309
+ name: deviceName,
310
+ platform: devicePlatform,
311
+ publicKey: deviceIdentity.publicKey,
312
+ capabilities,
313
+ roots: roots.map(root => ({ id: root.id, label: root.label })),
314
+ }),
315
+ });
316
+ const body = await boundedJson(response);
317
+ if (!response.ok || !body || typeof body !== "object" || Array.isArray(body)) {
318
+ const error = body && typeof body === "object" && "error" in body && typeof body.error === "string"
319
+ ? body.error
320
+ : `remote workspace pairing failed (${response.status})`;
321
+ throw new Error(error);
322
+ }
323
+ const raw = body as Record<string, unknown>;
324
+ const device = raw.device && typeof raw.device === "object" && !Array.isArray(raw.device)
325
+ ? raw.device as Record<string, unknown>
326
+ : null;
327
+ if (!device) throw new Error("remote workspace hub returned an invalid device");
328
+ const state = parseRemoteWorkspaceDeviceState({
329
+ version: REMOTE_WORKSPACE_DEVICE_STATE_VERSION,
330
+ hubUrl,
331
+ agentUrl: agentUrlForHub(hubUrl),
332
+ deviceId: device.id,
333
+ deviceName,
334
+ devicePlatform,
335
+ capabilities,
336
+ deviceToken: raw.deviceToken,
337
+ deviceIdentity,
338
+ hubPublicKey: raw.hubPublicKey,
339
+ roots,
340
+ toolchainRoots,
341
+ ...(nativeHelper ? { nativeHelper } : {}),
342
+ });
343
+ (options.store ?? new RemoteWorkspaceDeviceFileStore()).save(state);
344
+ return state;
345
+ }
346
+
347
+ function defaultWebSocketFactory(url: string, headers: Record<string, string>): RemoteWorkspaceWebSocketLike {
348
+ return new WebSocket(url, { headers } as unknown as string[]) as unknown as RemoteWorkspaceWebSocketLike;
349
+ }
350
+
351
+ async function messageBytes(event: MessageEvent): Promise<string | Uint8Array> {
352
+ if (typeof event.data === "string") return event.data;
353
+ if (event.data instanceof ArrayBuffer) return new Uint8Array(event.data);
354
+ if (ArrayBuffer.isView(event.data)) return new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
355
+ if (event.data instanceof Blob) return new Uint8Array(await event.data.arrayBuffer());
356
+ throw new Error("remote workspace agent received an unsupported frame");
357
+ }
358
+
359
+ export interface RemoteWorkspaceAgentHandle {
360
+ connected: Promise<void>;
361
+ closed: Promise<void>;
362
+ stop(): void;
363
+ }
364
+
365
+ export interface RemoteWorkspaceAgentRunStatus {
366
+ state: "connecting" | "online" | "reconnecting" | "stopped";
367
+ attempt: number;
368
+ message?: string;
369
+ }
370
+
371
+ /** Never advertise more authority than both local support and the pairing-time grant allow. */
372
+ export function remoteWorkspaceCapabilitiesForCommandRunner(
373
+ commandRunner: RemoteWorkspaceCommandRunner | undefined,
374
+ approved?: readonly RemoteWorkspaceCapability[],
375
+ ): RemoteWorkspaceCapability[] {
376
+ const available = parseRemoteWorkspaceCapabilities([
377
+ "workspace.read",
378
+ "workspace.write",
379
+ ...(commandRunner ? ["workspace.exec" as const] : []),
380
+ ]);
381
+ const requested = parseRemoteWorkspaceCapabilities(approved ?? available);
382
+ const allowed = new Set(available);
383
+ return parseRemoteWorkspaceCapabilities(requested.filter(capability => allowed.has(capability)));
384
+ }
385
+
386
+ export function connectRemoteWorkspaceAgent(options: {
387
+ state: RemoteWorkspaceDeviceState;
388
+ webSocketFactory?: RemoteWorkspaceWebSocketFactory;
389
+ commandRunner?: RemoteWorkspaceCommandRunner | null;
390
+ }): RemoteWorkspaceAgentHandle {
391
+ const state = parseRemoteWorkspaceDeviceState(options.state);
392
+ const commandRunner = options.commandRunner === undefined
393
+ ? createPlatformRemoteWorkspaceCommandRunner({
394
+ linux: {
395
+ toolchainRoots: state.toolchainRoots,
396
+ writableRoots: state.roots.map(root => root.path),
397
+ },
398
+ ...(state.nativeHelper ? { native: {
399
+ helper: state.nativeHelper,
400
+ toolchainRoots: state.toolchainRoots,
401
+ writableRoots: state.roots.map(root => root.path),
402
+ } } : {}),
403
+ })
404
+ : options.commandRunner ?? undefined;
405
+ const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(commandRunner, state.capabilities);
406
+ const executor = new RemoteWorkspaceExecutor({
407
+ deviceId: state.deviceId,
408
+ roots: state.roots.map(root => ({ id: root.id, path: root.path })),
409
+ commandRunner,
410
+ });
411
+ const socket = (options.webSocketFactory ?? defaultWebSocketFactory)(state.agentUrl, {
412
+ authorization: `Bearer ${state.deviceToken}`,
413
+ });
414
+ let agent: RemoteWorkspaceExecutorAgentConnection | null = null;
415
+ let opened = false;
416
+ let presenceAccepted = false;
417
+ let stopped = false;
418
+ let settleConnected!: () => void;
419
+ let rejectConnected!: (error: Error) => void;
420
+ let settleClosed!: () => void;
421
+ const connected = new Promise<void>((resolve, reject) => {
422
+ settleConnected = resolve;
423
+ rejectConnected = reject;
424
+ });
425
+ const closed = new Promise<void>(resolve => { settleClosed = resolve; });
426
+ let queue = Promise.resolve();
427
+ let heartbeat: ReturnType<typeof setInterval> | null = null;
428
+ let presenceTimer: ReturnType<typeof setTimeout> | null = null;
429
+ const acceptPresence = () => {
430
+ if (stopped) return;
431
+ if (presenceAccepted) return;
432
+ presenceAccepted = true;
433
+ if (presenceTimer) clearTimeout(presenceTimer);
434
+ presenceTimer = null;
435
+ settleConnected();
436
+ };
437
+
438
+ socket.addEventListener("open", () => {
439
+ if (stopped) {
440
+ try { socket.close(1000, "remote workspace agent stopped"); } catch { /* already closed */ }
441
+ return;
442
+ }
443
+ opened = true;
444
+ presenceTimer = setTimeout(() => {
445
+ rejectConnected(new Error("remote workspace Hub did not acknowledge executor capabilities"));
446
+ socket.close(1008, "remote workspace presence timed out");
447
+ }, 10_000);
448
+ agent = new RemoteWorkspaceExecutorAgentConnection({
449
+ deviceId: state.deviceId,
450
+ deviceIdentity: state.deviceIdentity,
451
+ hubPublicKey: state.hubPublicKey,
452
+ executor,
453
+ capabilities,
454
+ onPresenceAccepted: acceptPresence,
455
+ socket: {
456
+ send: value => socket.send(value),
457
+ close: (code, reason) => socket.close(code, reason),
458
+ },
459
+ });
460
+ socket.send(serializeRemoteWorkspaceAgentMessage({
461
+ version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION,
462
+ type: "presence",
463
+ capabilities,
464
+ }));
465
+ heartbeat = setInterval(() => {
466
+ if (socket.readyState !== 1) return;
467
+ socket.send(serializeRemoteWorkspaceAgentMessage({
468
+ version: REMOTE_WORKSPACE_AGENT_PROTOCOL_VERSION,
469
+ type: "heartbeat",
470
+ nonce: randomUUID(),
471
+ }));
472
+ }, 20_000);
473
+ });
474
+ socket.addEventListener("message", event => {
475
+ if (stopped || !(event instanceof MessageEvent) || !agent) return;
476
+ queue = queue.then(async () => agent?.receive(await messageBytes(event))).catch(() => {
477
+ socket.close(1008, "remote workspace protocol error");
478
+ });
479
+ });
480
+ socket.addEventListener("error", () => {
481
+ if (!stopped && !presenceAccepted) rejectConnected(new Error("remote workspace agent connection failed"));
482
+ });
483
+ socket.addEventListener("close", () => {
484
+ stopped = true;
485
+ if (presenceTimer) clearTimeout(presenceTimer);
486
+ presenceTimer = null;
487
+ if (heartbeat) clearInterval(heartbeat);
488
+ heartbeat = null;
489
+ const currentAgent = agent;
490
+ agent = null;
491
+ currentAgent?.close();
492
+ if (!presenceAccepted) rejectConnected(new Error(
493
+ opened
494
+ ? "remote workspace agent connection closed before presence acknowledgement"
495
+ : "remote workspace agent connection closed before opening",
496
+ ));
497
+ settleClosed();
498
+ });
499
+ return {
500
+ connected,
501
+ closed,
502
+ stop() {
503
+ if (stopped) return;
504
+ stopped = true;
505
+ if (presenceTimer) clearTimeout(presenceTimer);
506
+ presenceTimer = null;
507
+ if (heartbeat) clearInterval(heartbeat);
508
+ heartbeat = null;
509
+ const currentAgent = agent;
510
+ agent = null;
511
+ currentAgent?.close();
512
+ if (!presenceAccepted) rejectConnected(new Error("remote workspace agent stopped"));
513
+ settleClosed();
514
+ try { socket.close(1000, "remote workspace agent stopped"); } catch { /* CONNECTING sockets differ by runtime */ }
515
+ },
516
+ };
517
+ }
518
+
519
+ function waitForReconnect(delayMs: number, signal: AbortSignal): Promise<void> {
520
+ if (signal.aborted) return Promise.resolve();
521
+ return new Promise(resolve => {
522
+ const timer = setTimeout(finish, delayMs);
523
+ function finish() {
524
+ clearTimeout(timer);
525
+ signal.removeEventListener("abort", finish);
526
+ resolve();
527
+ }
528
+ signal.addEventListener("abort", finish, { once: true });
529
+ });
530
+ }
531
+
532
+ export async function runRemoteWorkspaceAgent(options: {
533
+ state: RemoteWorkspaceDeviceState;
534
+ signal: AbortSignal;
535
+ webSocketFactory?: RemoteWorkspaceWebSocketFactory;
536
+ commandRunner?: RemoteWorkspaceCommandRunner | null;
537
+ onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void;
538
+ minReconnectMs?: number;
539
+ maxReconnectMs?: number;
540
+ random?: () => number;
541
+ }): Promise<void> {
542
+ const state = parseRemoteWorkspaceDeviceState(options.state);
543
+ const minimum = options.minReconnectMs ?? 500;
544
+ const maximum = options.maxReconnectMs ?? 15_000;
545
+ if (!Number.isSafeInteger(minimum) || !Number.isSafeInteger(maximum) || minimum < 10 || maximum < minimum) {
546
+ throw new Error("invalid remote workspace reconnect policy");
547
+ }
548
+ let attempt = 0;
549
+ let delayMs = minimum;
550
+ while (!options.signal.aborted) {
551
+ attempt += 1;
552
+ options.onStatus?.({ state: "connecting", attempt });
553
+ const handle = connectRemoteWorkspaceAgent({
554
+ state,
555
+ ...(options.webSocketFactory ? { webSocketFactory: options.webSocketFactory } : {}),
556
+ ...(options.commandRunner !== undefined ? { commandRunner: options.commandRunner } : {}),
557
+ });
558
+ const stop = () => handle.stop();
559
+ options.signal.addEventListener("abort", stop, { once: true });
560
+ try {
561
+ await handle.connected;
562
+ delayMs = minimum;
563
+ options.onStatus?.({ state: "online", attempt });
564
+ await handle.closed;
565
+ } catch (error) {
566
+ handle.stop();
567
+ if (!options.signal.aborted) {
568
+ options.onStatus?.({
569
+ state: "reconnecting",
570
+ attempt,
571
+ message: error instanceof Error ? error.message : "remote workspace connection failed",
572
+ });
573
+ }
574
+ } finally {
575
+ options.signal.removeEventListener("abort", stop);
576
+ }
577
+ if (options.signal.aborted) break;
578
+ options.onStatus?.({ state: "reconnecting", attempt });
579
+ const random = Math.min(1, Math.max(0, (options.random ?? Math.random)()));
580
+ const jitteredDelay = Math.max(10, Math.round(delayMs * (0.8 + random * 0.4)));
581
+ await waitForReconnect(jitteredDelay, options.signal);
582
+ delayMs = Math.min(maximum, delayMs * 2);
583
+ }
584
+ options.onStatus?.({ state: "stopped", attempt });
585
+ }
@@ -0,0 +1,43 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { posix, win32 } from "node:path";
3
+
4
+ function executableCandidate(path: string, platform: NodeJS.Platform): boolean {
5
+ try {
6
+ if (!statSync(path).isFile()) return false;
7
+ accessSync(path, platform === "win32" ? constants.F_OK : constants.X_OK);
8
+ return true;
9
+ } catch {
10
+ return false;
11
+ }
12
+ }
13
+
14
+ /** Resolve only durable PATH entries; an empty/current-directory entry is never trusted. */
15
+ export function findExecutableOnPath(name: string, options: {
16
+ path?: string;
17
+ pathExt?: string;
18
+ platform?: NodeJS.Platform;
19
+ /** Pure cross-platform test seam; production checks the real filesystem. */
20
+ probe?: (candidate: string) => boolean;
21
+ } = {}): string | null {
22
+ const path = options.path ?? process.env.PATH;
23
+ const platform = options.platform ?? process.platform;
24
+ if (!path) return null;
25
+ const paths = platform === "win32" ? win32 : posix;
26
+ const spawnableWindowsExtensions = new Set([".com", ".exe", ".bat", ".cmd"]);
27
+ const suffixes = platform === "win32"
28
+ ? (options.pathExt ?? process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
29
+ .split(";")
30
+ .map(value => value.trim())
31
+ .filter(value => spawnableWindowsExtensions.has(value.toLowerCase()))
32
+ : [""];
33
+ if (platform === "win32" && win32.extname(name)) suffixes.unshift("");
34
+ const probe = options.probe ?? (candidate => executableCandidate(candidate, platform));
35
+ for (const directory of path.split(paths.delimiter)) {
36
+ if (!directory) continue;
37
+ for (const suffix of suffixes) {
38
+ const candidate = paths.join(directory, `${name}${suffix.toLowerCase()}`);
39
+ if (probe(candidate)) return candidate;
40
+ }
41
+ }
42
+ return null;
43
+ }