@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -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/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -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 {
@@ -178,3 +198,101 @@ export async function providerDestinationResolvedError(
178
198
  }
179
199
  return null;
180
200
  }
201
+
202
+ export interface UrlDestinationAssessment {
203
+ kind: DestinationKind;
204
+ detail: string;
205
+ }
206
+
207
+ export class DestinationDnsResolutionError extends Error {
208
+ override readonly name = "DestinationDnsResolutionError";
209
+ }
210
+
211
+ /**
212
+ * Synchronous literal URL destination assessment — classifies the hostname
213
+ * without DNS resolution. Returns null for unparseable URLs.
214
+ */
215
+ export function assessUrlDestination(url: string): UrlDestinationAssessment | null {
216
+ return assessDestination(url);
217
+ }
218
+
219
+ /**
220
+ * Async DNS-resolved URL safety check. By default, rejects every non-public
221
+ * address. Provider diagnostics may explicitly admit loopback/private answers;
222
+ * metadata, link-local, and unspecified addresses remain unconditionally denied.
223
+ * Returns the validated addresses so direct callers can pin the connect peer and
224
+ * avoid a second, rebindable resolution. DNS failures remain fail-closed here;
225
+ * the provider proxy wrapper alone may recognize that typed failure and degrade.
226
+ */
227
+ export async function resolvePublicAddresses(
228
+ url: string,
229
+ options?: string | { context?: string; allowPrivateNetwork?: boolean },
230
+ ): Promise<{
231
+ hostname: string;
232
+ addresses: { address: string; family: number }[];
233
+ privateNetwork: boolean;
234
+ }> {
235
+ const context = typeof options === "string"
236
+ ? `${options.trim() || "image"} URL`
237
+ : options?.context?.trim() || "image URL";
238
+ const privateNetworkAllowed = typeof options === "object" && options?.allowPrivateNetwork === true;
239
+ let hostname: string;
240
+ try {
241
+ hostname = normalizeHostname(new URL(url.trim()).hostname);
242
+ } catch {
243
+ throw new Error(`${context} is not a valid URL`);
244
+ }
245
+ if (!hostname) throw new Error(`${context} has no hostname`);
246
+ const literalAssessment = assessDestination(url);
247
+ let privateNetwork = false;
248
+ if (literalAssessment && literalAssessment.kind !== "public" && literalAssessment.kind !== "hostname") {
249
+ const allowedPrivateLiteral = privateNetworkAllowed
250
+ && (literalAssessment.kind === "localhost"
251
+ || literalAssessment.kind === "loopback"
252
+ || literalAssessment.kind === "private");
253
+ if (!allowedPrivateLiteral) throw new Error(`${context} targets ${literalAssessment.detail}`);
254
+ privateNetwork = true;
255
+ }
256
+ // Literal public IPs: no DNS round-trip; pin the literal itself.
257
+ const literalKind = isIP(hostname);
258
+ if (literalKind !== 0) {
259
+ return { hostname, addresses: [{ address: hostname, family: literalKind }], privateNetwork };
260
+ }
261
+ let addresses: { address: string; family: number }[];
262
+ try {
263
+ addresses = await lookup(hostname, { all: true, verbatim: true });
264
+ } catch {
265
+ // If DNS fails, we can't verify — fail-closed (unlike provider config-time validation,
266
+ // this is a runtime fetch to an untrusted URL, so be conservative).
267
+ throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
268
+ }
269
+ if (addresses.length === 0) {
270
+ throw new DestinationDnsResolutionError(`${context} hostname ${hostname} could not be resolved`);
271
+ }
272
+ const validatedAddresses: { address: string; family: number }[] = [];
273
+ for (const { address, family } of addresses) {
274
+ // Prefer classifying from the address string itself — do not trust a mislabeled
275
+ // resolver `family` that could skip IPv4/IPv6 private checks.
276
+ const ipKind = isIP(address) || (family === 4 || family === 6 ? family : 0);
277
+ const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
278
+ if (!assessment || assessment.kind !== "public") {
279
+ const allowedPrivateAddress = privateNetworkAllowed
280
+ && assessment
281
+ && (assessment.kind === "loopback" || assessment.kind === "private");
282
+ if (!allowedPrivateAddress) {
283
+ throw new Error(`${context} hostname ${hostname} resolves to ${assessment?.detail ?? "an unsafe address"} (${address})`);
284
+ }
285
+ privateNetwork = true;
286
+ }
287
+ validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
288
+ }
289
+ return { hostname, addresses: validatedAddresses, privateNetwork };
290
+ }
291
+
292
+ /**
293
+ * Void assertion wrapper around {@link resolvePublicAddresses} for call sites
294
+ * that only need the safety check.
295
+ */
296
+ export async function assertUrlResolvesPublic(url: string): Promise<void> {
297
+ await resolvePublicAddresses(url);
298
+ }
@@ -0,0 +1,151 @@
1
+ import http, { type IncomingMessage, type RequestOptions } from "node:http";
2
+ import https from "node:https";
3
+
4
+ export type PinnedAddress = { address: string; family: number };
5
+
6
+ export interface PinnedHttpGetOptions {
7
+ headers?: HeadersInit;
8
+ maxBytes?: number;
9
+ idleTimeoutMs?: number;
10
+ rejectUnauthorized?: boolean;
11
+ context?: string;
12
+ }
13
+
14
+ /**
15
+ * GET a URL through one previously validated address. The original hostname
16
+ * remains authoritative for Host, SNI, and certificate verification.
17
+ */
18
+ export function pinnedHttpGet(
19
+ url: string,
20
+ pinned: PinnedAddress,
21
+ signal?: AbortSignal,
22
+ options?: PinnedHttpGetOptions,
23
+ ): Promise<Response> {
24
+ const parsed = new URL(url);
25
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
26
+ throw new Error(`${options?.context ?? "request"} must use HTTP or HTTPS, got ${parsed.protocol}`);
27
+ }
28
+ const context = options?.context ?? "request";
29
+ const idleTimeoutMs = options?.idleTimeoutMs ?? 60_000;
30
+ const maxBytes = options?.maxBytes;
31
+ const headers = new Headers(options?.headers);
32
+ headers.set("host", parsed.host);
33
+ const requestHeaders: Record<string, string> = {};
34
+ headers.forEach((value, key) => { requestHeaders[key] = value; });
35
+
36
+ return new Promise<Response>((resolve, reject) => {
37
+ if (signal?.aborted) {
38
+ reject(signal.reason instanceof Error ? signal.reason : new Error("aborted"));
39
+ return;
40
+ }
41
+
42
+ let settled = false;
43
+ const fail = (error: unknown) => {
44
+ try { req.destroy(); } catch { /* ignore */ }
45
+ if (settled) return;
46
+ settled = true;
47
+ reject(error instanceof Error ? error : new Error(String(error)));
48
+ };
49
+ const requestOptions: RequestOptions & { servername?: string } = {
50
+ protocol: parsed.protocol,
51
+ hostname: parsed.hostname,
52
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
53
+ path: `${parsed.pathname}${parsed.search}`,
54
+ method: "GET",
55
+ headers: requestHeaders,
56
+ ...(parsed.protocol === "https:"
57
+ ? {
58
+ servername: parsed.hostname,
59
+ rejectUnauthorized: options?.rejectUnauthorized ?? true,
60
+ }
61
+ : {}),
62
+ lookup(_hostname, lookupOptions, callback) {
63
+ const opts = typeof lookupOptions === "function" ? undefined : lookupOptions;
64
+ const cb = typeof lookupOptions === "function" ? lookupOptions : callback;
65
+ if (!cb) return;
66
+ if (opts && typeof opts === "object" && "all" in opts && opts.all) {
67
+ (cb as (error: NodeJS.ErrnoException | null, addresses: PinnedAddress[]) => void)(
68
+ null,
69
+ [{ address: pinned.address, family: pinned.family }],
70
+ );
71
+ return;
72
+ }
73
+ (cb as (error: NodeJS.ErrnoException | null, address: string, family: 4 | 6) => void)(
74
+ null,
75
+ pinned.address,
76
+ pinned.family as 4 | 6,
77
+ );
78
+ },
79
+ };
80
+
81
+ const onResponse = (response: IncomingMessage) => {
82
+ const status = response.statusCode ?? 0;
83
+ const responseHeaders = new Headers();
84
+ for (const [key, value] of Object.entries(response.headers)) {
85
+ if (value === undefined || value === null) continue;
86
+ if (Array.isArray(value)) {
87
+ for (const item of value) responseHeaders.append(key, String(item));
88
+ } else {
89
+ responseHeaders.set(key, String(value));
90
+ }
91
+ }
92
+
93
+ if (status < 200 || status >= 300) {
94
+ try { response.destroy(); } catch { /* ignore */ }
95
+ try { req.destroy(); } catch { /* ignore */ }
96
+ if (settled) return;
97
+ settled = true;
98
+ resolve(new Response(null, { status, headers: responseHeaders }));
99
+ return;
100
+ }
101
+
102
+ let received = 0;
103
+ const stream = new ReadableStream<Uint8Array>({
104
+ start(controller) {
105
+ response.setTimeout(idleTimeoutMs, () => {
106
+ const error = new Error(`${context} stalled`);
107
+ fail(error);
108
+ try { controller.error(error); } catch { /* closed */ }
109
+ });
110
+ response.on("data", (chunk: Buffer | string) => {
111
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
112
+ received += buffer.byteLength;
113
+ if (maxBytes !== undefined && received > maxBytes) {
114
+ const error = new Error(`${context} exceeds ${maxBytes} byte cap`);
115
+ fail(error);
116
+ try { controller.error(error); } catch { /* closed */ }
117
+ return;
118
+ }
119
+ try { controller.enqueue(buffer); } catch { /* closed */ }
120
+ });
121
+ response.on("end", () => {
122
+ try { controller.close(); } catch { /* closed */ }
123
+ });
124
+ response.on("error", (error: Error) => {
125
+ fail(error);
126
+ try { controller.error(error); } catch { /* closed */ }
127
+ });
128
+ },
129
+ cancel() {
130
+ req.destroy();
131
+ },
132
+ });
133
+
134
+ if (settled) return;
135
+ settled = true;
136
+ resolve(new Response(stream, { status, headers: responseHeaders }));
137
+ };
138
+
139
+ const requestFn = parsed.protocol === "https:" ? https.request : http.request;
140
+ const req = requestFn(requestOptions, onResponse);
141
+ const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
142
+ signal?.addEventListener("abort", onAbort, { once: true });
143
+ req.setTimeout(idleTimeoutMs, () => fail(new Error(`${context} timed out`)));
144
+ req.on("error", error => {
145
+ signal?.removeEventListener("abort", onAbort);
146
+ fail(error);
147
+ });
148
+ req.on("close", () => signal?.removeEventListener("abort", onAbort));
149
+ req.end();
150
+ });
151
+ }
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { loadConfig, readRuntimePort } from "../config";
3
+ import { configuredAdminToken } from "./admin-secrets";
3
4
 
4
5
  export function isProcessAlive(pid: number): boolean {
5
6
  try {
@@ -66,8 +67,7 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
66
67
  if (!runtime?.port) return false;
67
68
  const env = io.env ?? process.env;
68
69
  const headers: Record<string, string> = {};
69
- // Non-loopback binds require management auth; loopback ignores the extra header.
70
- const token = env.OPENCODEX_API_AUTH_TOKEN?.trim();
70
+ const token = configuredAdminToken(env.OPENCODEX_HOME?.trim() || undefined, env as NodeJS.ProcessEnv);
71
71
  if (token) headers["x-opencodex-api-key"] = token;
72
72
  const fetchFn = io.fetchFn ?? fetch;
73
73
  try {