@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.0

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 (173) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +47 -22
  3. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  4. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/discovery.ts +4 -1
  10. package/src/adapters/cursor/effort-map.ts +3 -0
  11. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  12. package/src/adapters/cursor/protobuf-events.ts +24 -2
  13. package/src/adapters/cursor/protobuf-request.ts +1 -2
  14. package/src/adapters/cursor/tool-definitions.ts +68 -29
  15. package/src/adapters/google-wire-compiler.ts +4 -0
  16. package/src/adapters/google.ts +128 -2
  17. package/src/adapters/identity.ts +12 -2
  18. package/src/adapters/kiro.ts +64 -7
  19. package/src/adapters/mimo-free.ts +2 -0
  20. package/src/adapters/openai-responses.ts +246 -59
  21. package/src/claude/agents-inject.ts +5 -0
  22. package/src/claude/alias.ts +94 -14
  23. package/src/claude/inbound.ts +26 -9
  24. package/src/claude/outbound.ts +6 -3
  25. package/src/cli/account-auth.ts +1 -1
  26. package/src/cli/agent-driven.ts +37 -0
  27. package/src/cli/catalog-prewarm.ts +24 -0
  28. package/src/cli/claude.ts +35 -10
  29. package/src/cli/doctor.ts +71 -19
  30. package/src/cli/help.ts +42 -6
  31. package/src/cli/index.ts +93 -19
  32. package/src/cli/interactive-confirm.ts +133 -0
  33. package/src/cli/opencode.ts +701 -0
  34. package/src/cli/provider-runtime.ts +3 -0
  35. package/src/cli/provider.ts +31 -10
  36. package/src/cli/star-prompt.ts +79 -18
  37. package/src/cli/status.ts +47 -13
  38. package/src/cli/v2.ts +10 -1
  39. package/src/codex/account-id.ts +34 -0
  40. package/src/codex/account-lifecycle.ts +4 -1
  41. package/src/codex/account-namespace-match.ts +63 -0
  42. package/src/codex/account-namespaces.ts +149 -0
  43. package/src/codex/account-pause.ts +20 -0
  44. package/src/codex/account-store.ts +2 -0
  45. package/src/codex/account-usability.ts +6 -1
  46. package/src/codex/app-server-processes.ts +511 -0
  47. package/src/codex/auth-api.ts +293 -34
  48. package/src/codex/auth-collision.ts +2 -1
  49. package/src/codex/auth-context.ts +60 -17
  50. package/src/codex/catalog/bundled.ts +9 -2
  51. package/src/codex/catalog/parsing.ts +42 -2
  52. package/src/codex/catalog/provider-fetch.ts +264 -70
  53. package/src/codex/catalog/sync.ts +45 -8
  54. package/src/codex/catalog.ts +2 -2
  55. package/src/codex/features.ts +524 -5
  56. package/src/codex/history-provider.ts +145 -1
  57. package/src/codex/inject.ts +114 -14
  58. package/src/codex/main-account.ts +2 -8
  59. package/src/codex/pool-rotation.ts +186 -0
  60. package/src/codex/quota.ts +92 -2
  61. package/src/codex/routing.ts +695 -106
  62. package/src/codex/runtime.ts +10 -1
  63. package/src/codex/shim.ts +4 -1
  64. package/src/codex/subagent-defaults.ts +550 -0
  65. package/src/codex/subagent-model-fallback.ts +2 -0
  66. package/src/codex/sync.ts +3 -0
  67. package/src/config.ts +574 -25
  68. package/src/generated/jawcode-model-metadata.ts +12 -12
  69. package/src/github/star-state.ts +191 -0
  70. package/src/images/artifacts.ts +516 -0
  71. package/src/images/fulfill-video.ts +163 -0
  72. package/src/images/fulfill.ts +111 -0
  73. package/src/images/index.ts +4 -0
  74. package/src/images/loop.ts +789 -0
  75. package/src/images/plan.ts +133 -0
  76. package/src/images/synthetic-tool.ts +133 -0
  77. package/src/images/types.ts +41 -0
  78. package/src/images/xai-client.ts +141 -0
  79. package/src/images/xai-video-client.ts +163 -0
  80. package/src/lib/admin-secrets.ts +25 -0
  81. package/src/lib/bun-binary-validator.d.mts +3 -0
  82. package/src/lib/bun-binary-validator.mjs +18 -0
  83. package/src/lib/bun-runtime.ts +6 -20
  84. package/src/lib/config-ownership.ts +327 -0
  85. package/src/lib/crash-guard.ts +2 -0
  86. package/src/lib/destination-policy.ts +132 -7
  87. package/src/lib/pinned-http.ts +151 -0
  88. package/src/lib/process-control.ts +2 -2
  89. package/src/lib/provider-outbound.ts +167 -0
  90. package/src/lib/provider-url.ts +14 -0
  91. package/src/lib/proxy-env.ts +18 -0
  92. package/src/lib/shadow-call.ts +30 -0
  93. package/src/lib/test-home-guard.ts +90 -0
  94. package/src/lib/win-exec.ts +12 -2
  95. package/src/lib/windows-elevation.ts +81 -3
  96. package/src/lib/windows-secret-acl.ts +189 -12
  97. package/src/lib/winsw.ts +2 -0
  98. package/src/oauth/anthropic-routing.ts +570 -0
  99. package/src/oauth/health.ts +6 -0
  100. package/src/oauth/index.ts +310 -75
  101. package/src/oauth/key-providers.ts +38 -8
  102. package/src/oauth/kimi.ts +2 -0
  103. package/src/oauth/kiro-credentials.ts +373 -12
  104. package/src/oauth/kiro.ts +424 -43
  105. package/src/oauth/login-cli.ts +33 -6
  106. package/src/oauth/store.ts +56 -4
  107. package/src/oauth/types.ts +11 -0
  108. package/src/providers/alibaba-region-migration.ts +16 -3
  109. package/src/providers/antigravity-models.ts +3 -0
  110. package/src/providers/api-keys.ts +13 -6
  111. package/src/providers/derive.ts +8 -2
  112. package/src/providers/key-failover.ts +24 -4
  113. package/src/providers/model-discovery.ts +356 -0
  114. package/src/providers/quota.ts +233 -29
  115. package/src/providers/registry.ts +125 -3
  116. package/src/responses/parser.ts +11 -0
  117. package/src/responses/state.ts +22 -8
  118. package/src/responses/tool-groups.ts +19 -0
  119. package/src/router.ts +19 -7
  120. package/src/server/auth-cors.ts +114 -24
  121. package/src/server/claude-messages.ts +8 -1
  122. package/src/server/gui-static.ts +30 -6
  123. package/src/server/images.ts +303 -9
  124. package/src/server/index.ts +77 -9
  125. package/src/server/lifecycle.ts +25 -1
  126. package/src/server/live.ts +75 -25
  127. package/src/server/management/agent-settings-routes.ts +106 -8
  128. package/src/server/management/combo-routes.ts +7 -0
  129. package/src/server/management/config-routes.ts +22 -7
  130. package/src/server/management/context.ts +11 -1
  131. package/src/server/management/logs-usage-routes.ts +167 -3
  132. package/src/server/management/model-routes.ts +46 -13
  133. package/src/server/management/oauth-account-routes.ts +163 -17
  134. package/src/server/management/provider-routes.ts +73 -10
  135. package/src/server/management/shared.ts +2 -2
  136. package/src/server/management/sidebar-routes.ts +39 -0
  137. package/src/server/management/system-restart.ts +172 -0
  138. package/src/server/management/system-routes.ts +33 -10
  139. package/src/server/management-api.ts +5 -3
  140. package/src/server/management-auth.ts +216 -0
  141. package/src/server/proxy-liveness.ts +14 -3
  142. package/src/server/responses/compact.ts +21 -13
  143. package/src/server/responses/core.ts +614 -172
  144. package/src/server/responses/upstream-error.ts +48 -0
  145. package/src/server/responses-image-gen-repair.ts +118 -0
  146. package/src/server/responses-item-id-repair.ts +10 -85
  147. package/src/server/sse-payload-rewrite.ts +116 -0
  148. package/src/server/startup-action-control.ts +30 -14
  149. package/src/server/system-env.ts +28 -10
  150. package/src/service.ts +284 -19
  151. package/src/storage/cleanup-job.ts +57 -0
  152. package/src/storage/cleanup.ts +1504 -28
  153. package/src/storage/policy-job.ts +387 -0
  154. package/src/storage/policy-scheduler.ts +40 -0
  155. package/src/storage/policy-worker.ts +53 -0
  156. package/src/storage/policy.ts +522 -0
  157. package/src/storage/restore-job.ts +253 -0
  158. package/src/storage/restore-worker.ts +52 -0
  159. package/src/storage/storage-mutation-coordinator.ts +109 -0
  160. package/src/storage/worker-lifecycle.ts +81 -0
  161. package/src/tray/windows.ts +34 -4
  162. package/src/types.ts +107 -1
  163. package/src/update/badge.ts +72 -0
  164. package/src/update/index.ts +36 -18
  165. package/src/update/job.ts +111 -16
  166. package/src/update/npm-invocation.d.mts +23 -0
  167. package/src/update/npm-invocation.mjs +94 -0
  168. package/src/usage/debug.ts +2 -0
  169. package/src/usage/expected-prices.ts +6 -5
  170. package/src/usage/log.ts +12 -0
  171. package/src/web-search/loop.ts +57 -16
  172. package/gui/dist/assets/index-CjKFJHSC.js +0 -65
  173. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -11,15 +11,13 @@
11
11
  * back to `process.execPath` (which is itself Bun when run via `bun src/cli/index.ts`).
12
12
  */
13
13
  import { createRequire } from "node:module";
14
- import { existsSync, statSync } from "node:fs";
15
- import { dirname, join } from "node:path";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { isRealBunBinary } from "./bun-binary-validator.mjs";
16
+
17
+ export { isRealBunBinary };
16
18
 
17
19
  const require = createRequire(import.meta.url);
18
20
 
19
- // The `bun` package leaves a tiny ASCII placeholder at bin/bun.exe until its
20
- // postinstall downloads the real ~60MB binary; reject the stub by size so we
21
- // never bake a non-executable path into durable artifacts.
22
- const REAL_BUN_MIN_BYTES = 1_000_000;
23
21
  const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";
24
22
 
25
23
  export type DurableBunRuntime = {
@@ -28,19 +26,6 @@ export type DurableBunRuntime = {
28
26
  overrideEnv: typeof BUN_OVERRIDE_ENV;
29
27
  };
30
28
 
31
- /**
32
- * True only for a real, downloaded Bun binary — not the ~450-byte ASCII
33
- * placeholder stub left by `--ignore-scripts` / pnpm. A size gate cleanly
34
- * separates the two on every platform (real binary is tens of MB).
35
- */
36
- export function isRealBunBinary(path: string): boolean {
37
- try {
38
- return existsSync(path) && statSync(path).size >= REAL_BUN_MIN_BYTES;
39
- } catch {
40
- return false;
41
- }
42
- }
43
-
44
29
  /**
45
30
  * Absolute path to the bundled Bun binary, or null if the `bun` dependency is
46
31
  * not installed/resolvable (or only the un-downloaded placeholder is present).
@@ -63,7 +48,8 @@ export function bundledBunPath(): string | null {
63
48
  export function overrideBunPath(): string | null {
64
49
  const value = process.env[BUN_OVERRIDE_ENV]?.trim();
65
50
  if (!value) return null;
66
- return isRealBunBinary(value) ? value : null;
51
+ const resolved = resolve(value);
52
+ return isRealBunBinary(resolved) ? resolved : null;
67
53
  }
68
54
 
69
55
  export function durableBunRuntime(): DurableBunRuntime {
@@ -0,0 +1,327 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ realpathSync,
8
+ renameSync,
9
+ rmdirSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { randomUUID } from "node:crypto";
14
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
15
+
16
+ export const CONFIG_OWNER_FILE = ".opencodex-owner.json";
17
+ export const CONFIG_UNINSTALL_MANIFEST = ".opencodex-uninstall.json";
18
+
19
+ export type ConfigRemovalResult = {
20
+ status: "absent" | "removed" | "partial" | "refused";
21
+ reason?: string;
22
+ residualPaths: string[];
23
+ };
24
+
25
+ type ConfigOwner = {
26
+ version: 1;
27
+ ownerId: string;
28
+ root: string;
29
+ };
30
+
31
+ type ConfigUninstallManifest = ConfigOwner & {
32
+ paths: string[];
33
+ };
34
+
35
+ const METADATA_MAX_BYTES = 64 * 1024;
36
+ const MANIFEST_MAX_PATHS = 1024;
37
+ const INITIAL_OWNED_PATHS = [
38
+ ".star-prompted",
39
+ "artifacts",
40
+ "auth.json",
41
+ "auth.store.lock",
42
+ "catalog-backup.json",
43
+ "claude-env.sh",
44
+ "codex-accounts.json",
45
+ "codex-runtime-clamp.json",
46
+ "codex-runtime.json",
47
+ "codex-shim.autorestore.lock",
48
+ "codex-shim.json",
49
+ "config.json",
50
+ "crash.log",
51
+ "kimi-device-id",
52
+ "mimo-client-id",
53
+ "ocx.pid",
54
+ "opencodex-service-launcher.vbs",
55
+ "opencodex-service-task.xml",
56
+ "opencodex-service.cmd",
57
+ "opencodex-tray-offline.ico",
58
+ "opencodex-tray-online.ico",
59
+ "opencodex-tray-warning.ico",
60
+ "opencodex-tray.ps1",
61
+ "responses-state.json",
62
+ "runtime-port.json",
63
+ "service-api-token",
64
+ "service-state.json",
65
+ "service.log",
66
+ "system-env-port",
67
+ "tray-heartbeat.json",
68
+ "tray-state.json",
69
+ "update-job.json",
70
+ "usage-debug.jsonl",
71
+ "usage.jsonl",
72
+ "version.json",
73
+ "winsw",
74
+ ] as const;
75
+ const ownershipCache = new Map<string, {
76
+ owner: ConfigOwner;
77
+ manifest: ConfigUninstallManifest;
78
+ } | null>();
79
+
80
+ function ownershipCacheKey(configDir: string): string {
81
+ const key = resolve(configDir);
82
+ return process.platform === "win32" ? key.toLowerCase() : key;
83
+ }
84
+
85
+ function samePath(left: string, right: string): boolean {
86
+ return process.platform === "win32"
87
+ ? left.toLowerCase() === right.toLowerCase()
88
+ : left === right;
89
+ }
90
+
91
+ function readBoundedJson(path: string): unknown {
92
+ const metadata = lstatSync(path);
93
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
94
+ throw new Error("ownership metadata is not a regular file");
95
+ }
96
+ if (metadata.size > METADATA_MAX_BYTES) throw new Error("ownership metadata is too large");
97
+ return JSON.parse(readFileSync(path, "utf8")) as unknown;
98
+ }
99
+
100
+ function isOwner(value: unknown): value is ConfigOwner {
101
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
102
+ const owner = value as Record<string, unknown>;
103
+ return owner.version === 1
104
+ && typeof owner.ownerId === "string"
105
+ && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(owner.ownerId)
106
+ && typeof owner.root === "string";
107
+ }
108
+
109
+ function isManifest(value: unknown): value is ConfigUninstallManifest {
110
+ if (!isOwner(value)) return false;
111
+ const paths = (value as Record<string, unknown>).paths;
112
+ return Array.isArray(paths)
113
+ && paths.length <= MANIFEST_MAX_PATHS
114
+ && paths.every(path => typeof path === "string");
115
+ }
116
+
117
+ function canonicalRoot(configDir: string): string {
118
+ return realpathSync.native(resolve(configDir));
119
+ }
120
+
121
+ function isWithinRoot(root: string, candidate: string): boolean {
122
+ const rel = relative(root, candidate);
123
+ return rel === "" || (
124
+ rel !== ".."
125
+ && !rel.startsWith(`..${sep}`)
126
+ && !isAbsolute(rel)
127
+ );
128
+ }
129
+
130
+ function manifestRelativePath(configDir: string, candidatePath: string): string | null {
131
+ const root = resolve(configDir);
132
+ const candidate = resolve(candidatePath);
133
+ const rel = relative(root, candidate);
134
+ if (
135
+ !rel
136
+ || rel === ".."
137
+ || rel.startsWith(`..${sep}`)
138
+ || isAbsolute(rel)
139
+ ) return null;
140
+ const normalized = rel.split(sep).join("/");
141
+ if (normalized.split("/").some(part => !part || part === "." || part === ".." || part.includes("\\"))) {
142
+ return null;
143
+ }
144
+ if (normalized === CONFIG_OWNER_FILE || normalized === CONFIG_UNINSTALL_MANIFEST) return null;
145
+ return normalized;
146
+ }
147
+
148
+ function loadOwnership(configDir: string): { owner: ConfigOwner; manifest: ConfigUninstallManifest } | null {
149
+ const ownerPath = join(configDir, CONFIG_OWNER_FILE);
150
+ const manifestPath = join(configDir, CONFIG_UNINSTALL_MANIFEST);
151
+ if (!existsSync(ownerPath) || !existsSync(manifestPath)) return null;
152
+ try {
153
+ const owner = readBoundedJson(ownerPath);
154
+ const manifest = readBoundedJson(manifestPath);
155
+ const root = canonicalRoot(configDir);
156
+ if (
157
+ !isOwner(owner)
158
+ || !isManifest(manifest)
159
+ || owner.ownerId !== manifest.ownerId
160
+ || !samePath(owner.root, root)
161
+ || !samePath(manifest.root, root)
162
+ ) return null;
163
+ return { owner, manifest };
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ function createOwnership(configDir: string): { owner: ConfigOwner; manifest: ConfigUninstallManifest } | null {
170
+ const rootStat = lstatSync(configDir);
171
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || readdirSync(configDir).length !== 0) return null;
172
+ const owner: ConfigOwner = {
173
+ version: 1,
174
+ ownerId: randomUUID(),
175
+ root: canonicalRoot(configDir),
176
+ };
177
+ const manifest: ConfigUninstallManifest = { ...owner, paths: [...INITIAL_OWNED_PATHS] };
178
+ writeFileSync(join(configDir, CONFIG_OWNER_FILE), `${JSON.stringify(owner, null, 2)}\n`, {
179
+ encoding: "utf8",
180
+ flag: "wx",
181
+ mode: 0o600,
182
+ });
183
+ try {
184
+ writeFileSync(join(configDir, CONFIG_UNINSTALL_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`, {
185
+ encoding: "utf8",
186
+ flag: "wx",
187
+ mode: 0o600,
188
+ });
189
+ } catch (error) {
190
+ try { unlinkSync(join(configDir, CONFIG_OWNER_FILE)); } catch { /* incomplete metadata fails closed */ }
191
+ throw error;
192
+ }
193
+ return { owner, manifest };
194
+ }
195
+
196
+ function writeManifest(configDir: string, manifest: ConfigUninstallManifest): void {
197
+ const path = join(configDir, CONFIG_UNINSTALL_MANIFEST);
198
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
199
+ writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
200
+ try {
201
+ renameSync(temp, path);
202
+ } catch (error) {
203
+ try { unlinkSync(temp); } catch { /* best effort */ }
204
+ throw error;
205
+ }
206
+ }
207
+
208
+ function removeOwnedEntry(root: string, path: string): void {
209
+ const entry = lstatSync(path);
210
+ if (entry.isSymbolicLink()) {
211
+ unlinkSync(path);
212
+ return;
213
+ }
214
+ if (!entry.isDirectory()) {
215
+ unlinkSync(path);
216
+ return;
217
+ }
218
+
219
+ const realDirectory = realpathSync.native(path);
220
+ if (!isWithinRoot(root, realDirectory)) {
221
+ throw new Error(`owned directory resolves outside the config root: ${path}`);
222
+ }
223
+ for (const name of readdirSync(path)) {
224
+ removeOwnedEntry(root, join(path, name));
225
+ }
226
+ rmdirSync(path);
227
+ }
228
+
229
+ export function recordOwnedConfigPath(configDir: string, candidatePath: string): boolean {
230
+ const rel = manifestRelativePath(configDir, candidatePath);
231
+ if (!rel) return false;
232
+ const cacheKey = ownershipCacheKey(configDir);
233
+ if (!existsSync(configDir)) {
234
+ ownershipCache.delete(cacheKey);
235
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
236
+ }
237
+ let ownership = ownershipCache.get(cacheKey);
238
+ if (ownership === undefined) {
239
+ ownership = loadOwnership(configDir) ?? createOwnership(configDir);
240
+ ownershipCache.set(cacheKey, ownership);
241
+ }
242
+ if (!ownership) return false;
243
+ if (ownership.manifest.paths.includes(rel)) return true;
244
+ const manifest = {
245
+ ...ownership.manifest,
246
+ paths: [...ownership.manifest.paths, rel].sort(),
247
+ };
248
+ writeManifest(configDir, manifest);
249
+ ownershipCache.set(cacheKey, { owner: ownership.owner, manifest });
250
+ return true;
251
+ }
252
+
253
+ export function removeOwnedConfigState(configDir: string): ConfigRemovalResult {
254
+ ownershipCache.delete(ownershipCacheKey(configDir));
255
+ if (!existsSync(configDir)) return { status: "absent", residualPaths: [] };
256
+ const root = lstatSync(configDir);
257
+ if (!root.isDirectory() || root.isSymbolicLink()) {
258
+ return {
259
+ status: "refused",
260
+ reason: "config ownership root is not a real directory",
261
+ residualPaths: [configDir],
262
+ };
263
+ }
264
+ const ownership = loadOwnership(configDir);
265
+ if (!ownership) {
266
+ return {
267
+ status: "refused",
268
+ reason: "config ownership metadata is missing or invalid",
269
+ residualPaths: [configDir],
270
+ };
271
+ }
272
+
273
+ for (const rel of ownership.manifest.paths) {
274
+ const path = manifestRelativePath(configDir, join(configDir, ...rel.split("/")));
275
+ if (path !== rel) {
276
+ return {
277
+ status: "refused",
278
+ reason: "config ownership manifest contains an unsafe path",
279
+ residualPaths: [configDir],
280
+ };
281
+ }
282
+ }
283
+
284
+ const rootPath = canonicalRoot(configDir);
285
+ for (const rel of ownership.manifest.paths) {
286
+ const path = join(configDir, ...rel.split("/"));
287
+ if (!existsSync(path)) continue;
288
+ try {
289
+ removeOwnedEntry(rootPath, path);
290
+ } catch (error) {
291
+ return {
292
+ status: "partial",
293
+ reason: `could not remove owned path ${rel}: ${error instanceof Error ? error.message : String(error)}`,
294
+ residualPaths: [path],
295
+ };
296
+ }
297
+ }
298
+
299
+ try {
300
+ unlinkSync(join(configDir, CONFIG_UNINSTALL_MANIFEST));
301
+ unlinkSync(join(configDir, CONFIG_OWNER_FILE));
302
+ } catch (error) {
303
+ return {
304
+ status: "partial",
305
+ reason: `could not remove ownership metadata: ${error instanceof Error ? error.message : String(error)}`,
306
+ residualPaths: readdirSync(configDir).map(name => join(configDir, name)),
307
+ };
308
+ }
309
+ const residualPaths = readdirSync(configDir).map(name => join(configDir, name));
310
+ if (residualPaths.length > 0) {
311
+ return {
312
+ status: "partial",
313
+ reason: "unowned files remain in the config directory",
314
+ residualPaths,
315
+ };
316
+ }
317
+ try {
318
+ rmdirSync(configDir);
319
+ } catch (error) {
320
+ return {
321
+ status: "partial",
322
+ reason: `could not remove the empty config directory: ${error instanceof Error ? error.message : String(error)}`,
323
+ residualPaths: [configDir],
324
+ };
325
+ }
326
+ return { status: "removed", residualPaths: [] };
327
+ }
@@ -1,6 +1,7 @@
1
1
  import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getConfigDir } from "../config";
4
+ import { recordOwnedConfigPath } from "./config-ownership";
4
5
  import { redactSecretString, redactUrlForLog } from "./redact";
5
6
  import { sidecarBreadcrumb, activityBreadcrumb } from "./sidecar-tracker";
6
7
 
@@ -27,6 +28,7 @@ let installed = false;
27
28
  function crashLogPath(): string {
28
29
  const dir = getConfigDir();
29
30
  try {
31
+ recordOwnedConfigPath(dir, join(dir, "crash.log"));
30
32
  mkdirSync(dir, { recursive: true });
31
33
  } catch {
32
34
  /* best-effort: directory usually already exists */
@@ -19,7 +19,7 @@ const BLOCKED_METADATA_IPV6 = new Set([
19
19
  "fd00:ec2::254",
20
20
  ]);
21
21
 
22
- type DestinationKind =
22
+ export type DestinationKind =
23
23
  | "public"
24
24
  | "hostname"
25
25
  | "localhost"
@@ -79,13 +79,33 @@ function classifyIpv6(hostname: string): DestinationAssessment {
79
79
  if (BLOCKED_METADATA_IPV6.has(hostname)) return { kind: "metadata", detail: "blocked metadata endpoint" };
80
80
  const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)?.[1];
81
81
  if (mappedIpv4) return classifyIpv4(mappedIpv4);
82
+ // Decode hex IPv4-mapped IPv6: ::ffff:7f00:1 → 127.0.0.1
83
+ // The dotted-decimal regex above only matches ::ffff:127.0.0.1; without this,
84
+ // hex form bypasses all private/loopback checks (hextet is 0 → classified "public").
85
+ const hexMapped = hostname.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
86
+ if (hexMapped) {
87
+ const hi = Number.parseInt(hexMapped[1], 16);
88
+ const lo = Number.parseInt(hexMapped[2], 16);
89
+ const ipv4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
90
+ return classifyIpv4(ipv4);
91
+ }
82
92
  if (hostname === "::1") return { kind: "loopback", detail: "loopback address" };
83
93
  if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" };
84
94
  const hextet = firstIpv6Hextet(hostname);
85
- if (hextet === null) return { kind: "public", detail: "public IP" };
86
- if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" };
95
+ if (hextet === null) return { kind: "private", detail: "non-global address" };
96
+ // Multicast ff00::/8, deprecated site-local fec0::/10, ULA fc00::/7, link-local fe80::/10.
97
+ if (hextet >= 0xff00) return { kind: "private", detail: "multicast address" };
87
98
  if (hextet >= 0xfe80 && hextet <= 0xfebf) return { kind: "link-local", detail: "link-local address" };
88
- return { kind: "public", detail: "public IP" };
99
+ if (hextet >= 0xfec0 && hextet <= 0xfeff) return { kind: "private", detail: "site-local address" };
100
+ if (hextet >= 0xfc00 && hextet <= 0xfdff) return { kind: "private", detail: "private-network address" };
101
+ // Documentation 2001:db8::/32 (inside global-unicast 2000::/3).
102
+ if (hextet === 0x2001) {
103
+ const second = Number.parseInt(hostname.split(":")[1] || "0", 16);
104
+ if (second === 0xdb8) return { kind: "private", detail: "documentation address" };
105
+ }
106
+ // Only global unicast 2000::/3 is treated as a public image/CDN peer.
107
+ if (hextet >= 0x2000 && hextet <= 0x3fff) return { kind: "public", detail: "public IP" };
108
+ return { kind: "private", detail: "non-global address" };
89
109
  }
90
110
 
91
111
  function assessDestination(baseUrl: string): DestinationAssessment | null {
@@ -110,13 +130,20 @@ function registryAllowsPrivateNetwork(name: string): boolean {
110
130
  return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
111
131
  }
112
132
 
133
+ /** True when the provider config or registry default admits private/loopback destinations. */
134
+ export function providerAllowsPrivateNetwork(
135
+ name: string,
136
+ provider: Pick<OcxProviderConfig, "allowPrivateNetwork">,
137
+ ): boolean {
138
+ return provider.allowPrivateNetwork === true || registryAllowsPrivateNetwork(name);
139
+ }
140
+
113
141
  export function providerDestinationConfigError(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): string | null {
114
142
  const assessment = assessDestination(provider.baseUrl);
115
143
  if (!assessment) return null;
116
144
  if (assessment.kind === "public" || assessment.kind === "hostname") return null;
117
145
  if (assessment.kind === "metadata") return "baseUrl targets a blocked metadata endpoint";
118
- if (registryAllowsPrivateNetwork(name)) return null;
119
- if (provider.allowPrivateNetwork === true) return null;
146
+ if (providerAllowsPrivateNetwork(name, provider)) return null;
120
147
  return `baseUrl points to a ${assessment.detail}; set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
121
148
  }
122
149
 
@@ -154,7 +181,7 @@ export async function providerDestinationResolvedError(
154
181
  if (!hostname || isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) {
155
182
  return null; // literals and localhost are fully handled by the sync path
156
183
  }
157
- if (registryAllowsPrivateNetwork(name) || provider.allowPrivateNetwork === true) return null;
184
+ if (providerAllowsPrivateNetwork(name, provider)) return null;
158
185
  let addresses: { address: string }[];
159
186
  try {
160
187
  addresses = await lookup(hostname, { all: true, verbatim: true });
@@ -178,3 +205,101 @@ export async function providerDestinationResolvedError(
178
205
  }
179
206
  return null;
180
207
  }
208
+
209
+ export interface UrlDestinationAssessment {
210
+ kind: DestinationKind;
211
+ detail: string;
212
+ }
213
+
214
+ export class DestinationDnsResolutionError extends Error {
215
+ override readonly name = "DestinationDnsResolutionError";
216
+ }
217
+
218
+ /**
219
+ * Synchronous literal URL destination assessment — classifies the hostname
220
+ * without DNS resolution. Returns null for unparseable URLs.
221
+ */
222
+ export function assessUrlDestination(url: string): UrlDestinationAssessment | null {
223
+ return assessDestination(url);
224
+ }
225
+
226
+ /**
227
+ * Async DNS-resolved URL safety check. By default, rejects every non-public
228
+ * address. Provider diagnostics may explicitly admit loopback/private answers;
229
+ * metadata, link-local, and unspecified addresses remain unconditionally denied.
230
+ * Returns the validated addresses so direct callers can pin the connect peer and
231
+ * avoid a second, rebindable resolution. DNS failures remain fail-closed here;
232
+ * the provider proxy wrapper alone may recognize that typed failure and degrade.
233
+ */
234
+ export async function resolvePublicAddresses(
235
+ url: string,
236
+ options?: string | { context?: string; allowPrivateNetwork?: boolean },
237
+ ): Promise<{
238
+ hostname: string;
239
+ addresses: { address: string; family: number }[];
240
+ privateNetwork: boolean;
241
+ }> {
242
+ const context = typeof options === "string"
243
+ ? `${options.trim() || "image"} URL`
244
+ : options?.context?.trim() || "image URL";
245
+ const privateNetworkAllowed = typeof options === "object" && options?.allowPrivateNetwork === true;
246
+ let hostname: string;
247
+ try {
248
+ hostname = normalizeHostname(new URL(url.trim()).hostname);
249
+ } catch {
250
+ throw new Error(`${context} is not a valid URL`);
251
+ }
252
+ if (!hostname) throw new Error(`${context} has no hostname`);
253
+ const literalAssessment = assessDestination(url);
254
+ let privateNetwork = false;
255
+ if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") {
256
+ const allowedPrivateLiteral = privateNetworkAllowed
257
+ && (literalAssessment.kind === "localhost"
258
+ || literalAssessment.kind === "loopback"
259
+ || literalAssessment.kind === "private");
260
+ if (!allowedPrivateLiteral) throw new Error(`${context} targets ${literalAssessment.detail}`);
261
+ privateNetwork = true;
262
+ }
263
+ // Literal public IPs: no DNS round-trip; pin the literal itself.
264
+ const literalKind = isIP(hostname);
265
+ if (literalKind !== 0) {
266
+ return { hostname, addresses: [{ address: hostname, family: literalKind }], privateNetwork };
267
+ }
268
+ let addresses: { address: string; family: number }[];
269
+ try {
270
+ addresses = await lookup(hostname, { all: true, verbatim: true });
271
+ } catch {
272
+ // If DNS fails, we can't verify — fail-closed (unlike provider config-time validation,
273
+ // this is a runtime fetch to an untrusted URL, so be conservative).
274
+ throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
275
+ }
276
+ if (addresses.length === 0) {
277
+ throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
278
+ }
279
+ const validatedAddresses: { address: string; family: number }[] = [];
280
+ for (const { address, family } of addresses) {
281
+ // Prefer classifying from the address string itself — do not trust a mislabeled
282
+ // resolver `family` that could skip IPv4/IPv6 private checks.
283
+ const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0);
284
+ const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
285
+ if (!assessment || assessment.kind !== "public") {
286
+ const allowedPrivateAddress = privateNetworkAllowed
287
+ && assessment
288
+ && (assessment.kind === "loopback" || assessment.kind === "private");
289
+ if (!allowedPrivateAddress) {
290
+ throw new Error(`${context} hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`);
291
+ }
292
+ privateNetwork = true;
293
+ }
294
+ validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
295
+ }
296
+ return { hostname, addresses: validatedAddresses, privateNetwork };
297
+ }
298
+
299
+ /**
300
+ * Void assertion wrapper around {@link resolvePublicAddresses} for call sites
301
+ * that only need the safety check.
302
+ */
303
+ export async function assertUrlResolvesPublic(url: string): Promise<void> {
304
+ await resolvePublicAddresses(url);
305
+ }