@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.
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/cli/capabilities.ts +28 -0
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +94 -30
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +26 -10
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- 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
|
-
|
|
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 =
|
|
46
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
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;
|
package/src/vision/plan.ts
CHANGED
|
@@ -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: {
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
74
|
-
export function routedDescribeBaseUrl(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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,
|