@bitkyc08/opencodex 2.50.0 → 2.52.0-preview.20260911

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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
@@ -0,0 +1,141 @@
1
+ import { existsSync } from "node:fs";
2
+ import { win32 } from "node:path";
3
+
4
+ const CMD_META = /([()%!^"`<>&|;, *?])/g;
5
+
6
+ function escapeCmdArg(arg) {
7
+ const out = String(arg).replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\*)$/, "$1$1");
8
+ return `"${out}"`.replace(CMD_META, "^$1");
9
+ }
10
+
11
+ function escapeCmdCommand(command) {
12
+ return command.replace(CMD_META, "^$1");
13
+ }
14
+
15
+ function cleanPathEntry(entry) {
16
+ const trimmed = entry.trim();
17
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) return trimmed.slice(1, -1);
18
+ return trimmed;
19
+ }
20
+
21
+ function pathEntries(platform, env) {
22
+ const raw = env.PATH ?? env.Path ?? "";
23
+ const delimiter = platform === "win32" ? win32.delimiter : ":";
24
+ return raw.split(delimiter).map(cleanPathEntry).filter(Boolean);
25
+ }
26
+
27
+ function isCurrentDirectory(cwd, entry) {
28
+ const left = win32.resolve(entry);
29
+ const right = win32.resolve(cwd);
30
+ return left.toLowerCase() === right.toLowerCase();
31
+ }
32
+
33
+ function systemCommandProcessor(env) {
34
+ const systemRoot = env.SystemRoot ?? env.windir;
35
+ if (systemRoot && win32.isAbsolute(systemRoot)) {
36
+ return win32.join(systemRoot, "System32", "cmd.exe");
37
+ }
38
+ const comSpec = env.ComSpec;
39
+ return comSpec && win32.isAbsolute(comSpec) ? win32.resolve(comSpec) : null;
40
+ }
41
+
42
+ function commandPaths(platform, env, deps) {
43
+ const exists = deps.exists ?? existsSync;
44
+ const cwd = deps.cwd ?? process.cwd();
45
+ const entries = pathEntries(platform, env);
46
+ const paths = [];
47
+
48
+ if (platform !== "win32") {
49
+ for (const entry of entries) {
50
+ if (!entry.startsWith("/")) continue;
51
+ const candidate = `${entry}/pnpm`;
52
+ if (exists(candidate) && !paths.includes(candidate)) paths.push(candidate);
53
+ }
54
+ return paths;
55
+ }
56
+
57
+ const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
58
+ .split(";")
59
+ .filter(Boolean);
60
+ for (const entry of entries) {
61
+ if (!win32.isAbsolute(entry) || isCurrentDirectory(cwd, entry)) continue;
62
+ for (const extension of extensions) {
63
+ const candidate = win32.join(entry, `pnpm${extension.toLowerCase()}`);
64
+ if (exists(candidate)) {
65
+ const resolved = win32.resolve(candidate);
66
+ if (!paths.some(path => path.toLowerCase() === resolved.toLowerCase())) paths.push(resolved);
67
+ }
68
+ }
69
+ }
70
+ return paths;
71
+ }
72
+
73
+ function invocationForPath(pnpm, args, platform, env) {
74
+ if (platform !== "win32" || !/\.(cmd|bat)$/i.test(pnpm)) {
75
+ return { file: pnpm, args: [...args], options: {} };
76
+ }
77
+
78
+ const commandProcessor = systemCommandProcessor(env);
79
+ if (!commandProcessor) return null;
80
+ const line = [escapeCmdCommand(pnpm), ...args.map(escapeCmdArg)].join(" ");
81
+ return {
82
+ file: commandProcessor,
83
+ args: ["/d", "/s", "/c", `"${line}"`],
84
+ options: { windowsVerbatimArguments: true },
85
+ };
86
+ }
87
+
88
+ /** Return every absolute pnpm executable candidate in PATH, in shell order. */
89
+ export function resolvePnpmCommands(
90
+ platform = process.platform,
91
+ env = process.env,
92
+ deps = {},
93
+ ) {
94
+ return commandPaths(platform, env, deps);
95
+ }
96
+
97
+ /** Build an invocation for one already-selected pnpm executable. */
98
+ export function pnpmInvocationForPath(
99
+ pnpm,
100
+ args,
101
+ platform = process.platform,
102
+ env = process.env,
103
+ ) {
104
+ return invocationForPath(pnpm, args, platform, env);
105
+ }
106
+
107
+ /**
108
+ * Resolve pnpm without relying on cmd.exe's implicit current-directory lookup on Windows.
109
+ * POSIX returns an absolute PATH candidate as well, so a service receives the same
110
+ * executable that the interactive shell selected.
111
+ */
112
+ export function resolvePnpmCommand(
113
+ platform = process.platform,
114
+ env = process.env,
115
+ deps = {},
116
+ ) {
117
+ return resolvePnpmCommands(platform, env, deps)[0] ?? null;
118
+ }
119
+
120
+ export function pnpmInvocation(
121
+ args,
122
+ platform = process.platform,
123
+ env = process.env,
124
+ deps = {},
125
+ ) {
126
+ const pnpm = resolvePnpmCommand(platform, env, deps);
127
+ if (!pnpm) return null;
128
+ return invocationForPath(pnpm, args, platform, env);
129
+ }
130
+
131
+ /** Return invocations for every absolute pnpm candidate in PATH. */
132
+ export function pnpmInvocations(
133
+ args,
134
+ platform = process.platform,
135
+ env = process.env,
136
+ deps = {},
137
+ ) {
138
+ return resolvePnpmCommands(platform, env, deps)
139
+ .map(command => invocationForPath(command, args, platform, env))
140
+ .filter(Boolean);
141
+ }
@@ -0,0 +1,16 @@
1
+ export interface RegistryCommandResult {
2
+ status: number | null;
3
+ stdout?: string | Uint8Array | null;
4
+ stderr?: string | Uint8Array | null;
5
+ }
6
+
7
+ export type RegistryIntegrityResult =
8
+ | { ok: true; integrity: string }
9
+ | { ok: false; reason: string }
10
+ | { ok: "skipped"; reason: string };
11
+
12
+ export declare function checkRegistryPackageIntegrity(
13
+ packageName: string,
14
+ version: string | null | undefined,
15
+ run: (args: readonly string[], capture?: boolean) => RegistryCommandResult,
16
+ ): RegistryIntegrityResult;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared registry metadata pre-flight for every direct package-manager launcher.
3
+ *
4
+ * This intentionally does not perform the query itself. The caller supplies the
5
+ * already-hardened npm/pnpm invocation, so the plain Node launcher and the Bun
6
+ * update worker apply exactly the same integrity policy without importing TypeScript
7
+ * into the published launcher.
8
+ */
9
+ function outputText(value) {
10
+ if (typeof value === "string") return value;
11
+ if (value instanceof Uint8Array) return new TextDecoder().decode(value);
12
+ return "";
13
+ }
14
+
15
+ /**
16
+ * Check the registry's dist.integrity value for one immutable package version.
17
+ * A failed query is a best-effort skip; successful metadata without a sha512 SRI
18
+ * value is anomalous and fails closed before the caller changes local state.
19
+ */
20
+ export function checkRegistryPackageIntegrity(packageName, version, run) {
21
+ if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" };
22
+
23
+ let result;
24
+ try {
25
+ result = run(["view", `${packageName}@${version}`, "dist.integrity"], true);
26
+ } catch {
27
+ return { ok: "skipped", reason: "registry integrity query failed" };
28
+ }
29
+ if (result?.status !== 0) {
30
+ return { ok: "skipped", reason: `registry integrity query failed (status ${result?.status ?? "timeout"})` };
31
+ }
32
+
33
+ const tokens = outputText(result.stdout).replace(/["']/g, "").trim().split(/\s+/).filter(Boolean);
34
+ const integrity = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token));
35
+ if (!integrity) return { ok: false, reason: `registry returned no sha512 integrity for ${packageName}@${version}` };
36
+ return { ok: true, integrity };
37
+ }
@@ -1,5 +1,6 @@
1
1
  export type InstallTreeVerification = { ok: boolean; failures: string[] };
2
2
  export function verifyInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification;
3
+ export function verifyPnpmInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification;
3
4
  export function bootRestoreProbe(
4
5
  packageDir: string,
5
6
  deps?: { rename?: (from: string, to: string) => void },
@@ -19,4 +20,3 @@ export function transactionalNpmUpdate(args: {
19
20
  rolledBack?: boolean;
20
21
  backup?: string;
21
22
  };
22
-
@@ -17,11 +17,88 @@
17
17
  * <scopeDir>/.ocx-recovery.json double-fault marker with a one-line restore
18
18
  */
19
19
  import { spawnSync } from "node:child_process";
20
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
21
- import { dirname, join } from "node:path";
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
21
+ import { basename, dirname, join } from "node:path";
22
+
23
+ /**
24
+ * Dependency lookup confined to the candidate's OWN tree. This is the npm contract and it
25
+ * must stay lexical: Node's resolver walks the ancestor directory chain, so a global npm
26
+ * candidate at <prefix>/lib/node_modules/@scope/pkg could satisfy its bundled-Bun
27
+ * requirement from <prefix>/lib/node_modules/bun, which belongs to a different package.
28
+ * That verdict is not cosmetic — it accepts a stage that cannot start (D2), skips the
29
+ * post-swap rollback, and lets bootRestoreProbe reap the only known-good backup.
30
+ */
31
+ function candidateTreeDependencyDir(packageDir, name) {
32
+ const dir = join(packageDir, "node_modules", ...name.split("/"));
33
+ return existsSync(join(dir, "package.json")) ? dir : undefined;
34
+ }
35
+
36
+ /**
37
+ * The candidate's own bun directory, whether or not it carries a readable package.json.
38
+ * The size gate keys on the DIRECTORY, matching the pre-carry verifier: a half-extracted
39
+ * node_modules/bun holding a truncated binary and no manifest is still a broken tree, and
40
+ * bun is not always among the sentinels, so the sentinel loop cannot be relied on to catch it.
41
+ */
42
+ function ownTreeBunDir(packageDir) {
43
+ const dir = join(packageDir, "node_modules", "bun");
44
+ return existsSync(dir) ? dir : undefined;
45
+ }
46
+
47
+ /** The node_modules directory a package sits directly inside, or undefined. */
48
+ function enclosingNodeModules(packageDir) {
49
+ const parent = dirname(packageDir);
50
+ if (basename(parent) === "node_modules") return parent;
51
+ // Scoped packages live one level deeper: <node_modules>/@scope/name.
52
+ const grandparent = dirname(parent);
53
+ if (basename(parent).startsWith("@") && basename(grandparent) === "node_modules") return grandparent;
54
+ return undefined;
55
+ }
56
+
57
+ /** pnpm's own bookkeeping at the root of a node_modules tree it manages. */
58
+ function isPnpmManagedRoot(nodeModulesDir) {
59
+ if (!nodeModulesDir) return false;
60
+ if (nodeModulesDir.split(/[\\/]/).includes(".pnpm")) return true;
61
+ return existsSync(join(nodeModulesDir, ".pnpm")) || existsSync(join(nodeModulesDir, ".modules.yaml"));
62
+ }
63
+
64
+ /**
65
+ * Dependency roots this package INSTANCE owns. pnpm exposes dependencies in several shapes —
66
+ * symlinks inside the package's own node_modules, a package root that is itself a symlink into
67
+ * the virtual store, or a hoisted group root — so the npm rule alone rejects healthy trees.
68
+ * Ownership is still bounded: an enclosing node_modules counts only when pnpm's own metadata
69
+ * says pnpm manages it, which keeps an unrelated ancestor installation out.
70
+ */
71
+ function ownedDependencyRoots(packageDir) {
72
+ const roots = [];
73
+ const add = dir => { if (dir && !roots.includes(dir)) roots.push(dir); };
74
+ const lexicalGroup = enclosingNodeModules(packageDir);
75
+ add(join(packageDir, "node_modules"));
76
+ let real;
77
+ try { real = realpathSync(packageDir); } catch { /* keep the lexical path only */ }
78
+ if (real && real !== packageDir) add(join(real, "node_modules"));
79
+ if (isPnpmManagedRoot(lexicalGroup)) add(lexicalGroup);
80
+ const realGroup = real ? enclosingNodeModules(real) : undefined;
81
+ if (isPnpmManagedRoot(realGroup)) add(realGroup);
82
+ return roots;
83
+ }
84
+
85
+ /**
86
+ * pnpm dependency lookup. Probing the owned roots directly, rather than filtering whatever
87
+ * Node's resolver returned, is deliberate: require.resolve reports the REALPATH of the
88
+ * resolved file, so a dependency reached through pnpm's own node_modules symlink comes back
89
+ * as a virtual-store path that no lexical ownership test can recognise. existsSync follows
90
+ * the symlink, which is exactly the pnpm graph edge that proves ownership.
91
+ */
92
+ function pnpmOwnedDependencyDir(packageDir, name) {
93
+ for (const root of ownedDependencyRoots(packageDir)) {
94
+ const dir = join(root, ...name.split("/"));
95
+ if (existsSync(join(dir, "package.json"))) return dir;
96
+ }
97
+ return undefined;
98
+ }
22
99
 
23
100
  /** Verification manifest for a staged (or live) package tree. */
24
- export function verifyInstallTree(packageDir, expectedVersion) {
101
+ function verifyTreeWithDependencyLookup(packageDir, expectedVersion, dependencyDir) {
25
102
  const failures = [];
26
103
  let pkg;
27
104
  try {
@@ -42,8 +119,8 @@ export function verifyInstallTree(packageDir, expectedVersion) {
42
119
  // The bundled Bun binary is the load-bearing artifact: without it the launcher exits
43
120
  // before serving anything, and a boot probe that called this tree healthy would reap
44
121
  // the only backup (review High 3). Size-gate the real binary, not just its package.json.
45
- const bunPkgDir = join(packageDir, "node_modules", "bun");
46
- if (existsSync(bunPkgDir)) {
122
+ const bunPkgDir = dependencyDir(packageDir, "bun") ?? ownTreeBunDir(packageDir);
123
+ if (bunPkgDir) {
47
124
  const bunBinary = findLargestFile(bunPkgDir);
48
125
  if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) {
49
126
  failures.push("bundled Bun binary missing or truncated (< 10MB)");
@@ -56,12 +133,29 @@ export function verifyInstallTree(packageDir, expectedVersion) {
56
133
  ? deps.filter(name => name === "bun" || name === "zod")
57
134
  : deps.slice(0, 2);
58
135
  for (const name of sentinels) {
59
- const depPkg = join(packageDir, "node_modules", ...name.split("/"), "package.json");
60
- if (!existsSync(depPkg)) failures.push("sentinel dependency missing: " + name);
136
+ if (!dependencyDir(packageDir, name)) failures.push("sentinel dependency missing: " + name);
61
137
  }
62
138
  return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures };
63
139
  }
64
140
 
141
+ /**
142
+ * npm (and every recovery decision): the candidate must be self-contained. Used by
143
+ * transactionalNpmUpdate's stage and post-swap checks and by bootRestoreProbe.
144
+ */
145
+ export function verifyInstallTree(packageDir, expectedVersion) {
146
+ return verifyTreeWithDependencyLookup(packageDir, expectedVersion, candidateTreeDependencyDir);
147
+ }
148
+
149
+ /**
150
+ * Verify a package exposed through pnpm's global virtual store. pnpm 10/11 may use an
151
+ * isolated virtual store, a custom virtualStoreDir, global virtual-store links, or a
152
+ * hoisted linker, so the dependency may sit outside the package directory — but it must
153
+ * still be reachable through a root this package instance owns.
154
+ */
155
+ export function verifyPnpmInstallTree(packageDir, expectedVersion) {
156
+ return verifyTreeWithDependencyLookup(packageDir, expectedVersion, pnpmOwnedDependencyDir);
157
+ }
158
+
65
159
  function stampedName(prefix) {
66
160
  return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-");
67
161
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared, side-effect-free contract for preserving the Windows tray across all
3
- * updater entry points (npm launcher, CLI updater, and GUI worker).
3
+ * updater entry points (package launcher, CLI updater, and GUI worker).
4
4
  */
5
5
  export function planWindowsTrayUpdate(status) {
6
6
  const installed = status?.installed === true;
@@ -110,8 +110,8 @@ export interface VisionPlan {
110
110
  anthropicSidecar?: AnthropicVisionProvider;
111
111
  /** Namespaced "provider/model" describer for the routed backend (roadmap 180). */
112
112
  routedModel?: string;
113
- /** Loopback dispatch inputs for the routed backend. */
114
- routedConfig?: Pick<OcxConfig, "port" | "apiKeys">;
113
+ /** Loopback dispatch inputs for the routed backend (the listener decides WHICH local port). */
114
+ routedConfig?: Pick<OcxConfig, "port" | "hostname" | "apiKeys" | "unauthenticatedLoopbackListener">;
115
115
  settings: VisionSettings;
116
116
  maxDescriptionsPerTurn: number;
117
117
  }
@@ -153,7 +153,17 @@ export function planVisionSidecar(
153
153
  return {
154
154
  backend: "routed",
155
155
  routedModel,
156
- routedConfig: { port: config.port, ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}) },
156
+ routedConfig: {
157
+ port: config.port,
158
+ ...(config.apiKeys ? { apiKeys: config.apiKeys } : {}),
159
+ // The self-fetch has to honor the unauthenticated loopback listener AND, with no
160
+ // listener, the bind address — so BOTH fields the destination resolver reads have to
161
+ // survive the narrowing or it silently resolves to the wrong local socket (#4236).
162
+ ...(config.hostname ? { hostname: config.hostname } : {}),
163
+ ...(config.unauthenticatedLoopbackListener
164
+ ? { unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener }
165
+ : {}),
166
+ },
157
167
  settings: {
158
168
  model: routedModel,
159
169
  reasoning: DEFAULT_REASONING,
@@ -19,16 +19,18 @@
19
19
  * admission secret in a forwardable header is a forwarding hazard). Loopback
20
20
  * binds require no token at all (resolveApiAuth admits loopback).
21
21
  *
22
- * Known limitation (recorded in roadmap 170): a bindHost where 127.0.0.1
23
- * does not answer cannot reach its own loopback — same latent limitation
24
- * gateway-cache has.
22
+ * Destination (#4236): the unauthenticated loopback listener when one is
23
+ * enabled, otherwise the BIND address — the former roadmap-170 limitation
24
+ * ("a bindHost where 127.0.0.1 does not answer cannot reach its own
25
+ * loopback") is closed by resolving through `localInferenceDestination`
26
+ * rather than composing 127.0.0.1 by hand.
25
27
  */
26
28
  import type { OcxConfig } from "../types";
29
+ import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
27
30
  import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
28
31
  import { redactSecretString } from "../lib/redact";
29
32
  import { sidecarEnter } from "../lib/sidecar-tracker";
30
- import { configuredApiAuthToken, configuredPort } from "../server/auth-cors";
31
- import { loadServiceTokenFromFile } from "../lib/service-secrets";
33
+ import { configuredPort } from "../server/auth-cors";
32
34
  import type { DescribeOutcome, VisionSettings } from "./describe";
33
35
 
34
36
  export const VISION_DESCRIBE_TERMINAL_HEADER = "x-opencodex-vision-describe";
@@ -60,23 +62,43 @@ function validateImageUrl(url: string): string | null {
60
62
  return "unsupported image URL scheme (expected data: or https:)";
61
63
  }
62
64
 
63
- /** The admission ladder: env token, service token file, first configured API key. */
65
+ /**
66
+ * The admission ladder: env token, hardened service token file, first configured API key.
67
+ *
68
+ * Shared with every other local client through `localAdmissionToken` so the credential this
69
+ * self-fetch presents cannot drift from the one the Codex provider table and the Claude launch
70
+ * env carry. Never the admin token.
71
+ */
64
72
  export function routedDescribeAdmissionToken(config: Pick<OcxConfig, "apiKeys">): string | undefined {
65
- const envToken = configuredApiAuthToken();
66
- if (envToken) return envToken;
67
- const fileToken = loadServiceTokenFromFile(process.env);
68
- if (fileToken) return fileToken;
69
- const first = config.apiKeys?.[0]?.key?.trim();
70
- return first || undefined;
73
+ return localAdmissionToken(config);
71
74
  }
72
75
 
73
- /** Base URL seam for tests; production always self-fetches loopback. */
74
- export function routedDescribeBaseUrl(config: Pick<OcxConfig, "port">): string {
75
- // config.port can be 0 (ephemeral bind, tests) or stale after a live port
76
- // override; the server records its ACTUAL bound port via setCorsOrigin at
77
- // startup, so prefer that when config carries no positive port.
78
- const port = config.port && config.port > 0 ? String(config.port) : configuredPort();
79
- return `http://127.0.0.1:${port}`;
76
+ /** Base URL seam for tests; production always self-fetches the resolved local destination. */
77
+ export function routedDescribeBaseUrl(
78
+ config: Pick<OcxConfig, "port" | "hostname" | "unauthenticatedLoopbackListener">,
79
+ ): string {
80
+ return routedDescribeDestination(config).origin;
81
+ }
82
+
83
+ /**
84
+ * The local destination this self-fetch dials, and whether it needs a credential.
85
+ *
86
+ * This is a local client like any other: the unauthenticated loopback listener when one is
87
+ * enabled, otherwise the bind address — on a tailnet-bound hub there is no loopback socket at
88
+ * all (#4236). The helper sends the OpenAI chat wire, which that listener now admits.
89
+ */
90
+ function routedDescribeDestination(
91
+ config: Pick<OcxConfig, "port" | "hostname" | "unauthenticatedLoopbackListener">,
92
+ ) {
93
+ // config.port can be 0 (ephemeral bind, tests) or stale after a live port override; the
94
+ // server records its ACTUAL bound port via setCorsOrigin at startup, so prefer that when
95
+ // config carries no positive port. `configuredPort()` is itself `0` when `_corsOrigin` has no
96
+ // explicit port (a default-port origin), so the literal default has to backstop it or the
97
+ // composed URL names port 0 and the self-fetch cannot connect.
98
+ const port = config.port && config.port > 0
99
+ ? config.port
100
+ : Number(configuredPort()) || 10_100;
101
+ return localInferenceDestination(config, port);
80
102
  }
81
103
 
82
104
  export async function describeImageRouted(
@@ -84,7 +106,7 @@ export async function describeImageRouted(
84
106
  _detail: string | undefined,
85
107
  contextText: string,
86
108
  routedModel: string,
87
- config: Pick<OcxConfig, "port" | "apiKeys">,
109
+ config: Pick<OcxConfig, "port" | "hostname" | "apiKeys" | "unauthenticatedLoopbackListener">,
88
110
  settings: VisionSettings,
89
111
  abortSignal?: AbortSignal,
90
112
  baseUrlOverride?: string,
@@ -98,6 +120,15 @@ export async function describeImageRouted(
98
120
  };
99
121
  const admission = routedDescribeAdmissionToken(config);
100
122
  if (admission) headers["x-opencodex-api-key"] = admission;
123
+ // A bind that demands admission with no resolvable credential would return 401 with a body
124
+ // the caller reports as a describe failure; naming the cause once is the difference between
125
+ // "vision is broken" and a fixable configuration note.
126
+ if (!admission && !baseUrlOverride && routedDescribeDestination(config).requiresAdmissionToken) {
127
+ console.warn(
128
+ "[vision] routed describe has no opencodex data-plane credential for "
129
+ + `${routedDescribeBaseUrl(config)} — the self-fetch will be refused.`,
130
+ );
131
+ }
101
132
 
102
133
  const requestBody = {
103
134
  model: routedModel,