@bitkyc08/opencodex 2.7.43 → 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.
- package/bin/ocx.mjs +34 -8
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/kiro.ts +15 -1
- package/src/claude/alias.ts +94 -14
- package/src/claude/outbound.ts +6 -3
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/interactive-confirm.ts +5 -1
- package/src/cli/star-prompt.ts +26 -4
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-store.ts +2 -0
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +26 -1
- package/src/codex/catalog/provider-fetch.ts +240 -82
- package/src/codex/catalog/sync.ts +27 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +524 -5
- package/src/codex/quota.ts +77 -2
- package/src/codex/runtime.ts +10 -1
- package/src/config.ts +8 -0
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/destination-policy.ts +10 -3
- package/src/lib/provider-outbound.ts +5 -2
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +57 -8
- package/src/oauth/kiro.ts +2 -1
- package/src/oauth/login-cli.ts +1 -1
- package/src/oauth/store.ts +2 -0
- package/src/providers/derive.ts +2 -2
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/registry.ts +114 -0
- package/src/router.ts +5 -3
- package/src/server/auth-cors.ts +4 -2
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +78 -4
- package/src/server/management/config-routes.ts +19 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +31 -20
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/service.ts +237 -19
- package/src/storage/policy-job.ts +26 -5
- package/src/storage/restore-job.ts +16 -5
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +32 -4
- package/src/types.ts +11 -0
- package/src/update/badge.ts +72 -0
- package/src/update/job.ts +8 -4
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +8 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-Czw-jpTU.css +0 -1
- package/gui/dist/assets/index-cmds12BG.js +0 -67
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// The `bun` package leaves a tiny ASCII placeholder at bin/bun.exe until its
|
|
4
|
+
// postinstall downloads the real ~60MB binary. Keep the threshold and the
|
|
5
|
+
// false-on-filesystem-error contract shared by the Node launcher and Bun code.
|
|
6
|
+
export const REAL_BUN_MIN_BYTES = 1_000_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {string} path
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
export function isRealBunBinary(path) {
|
|
13
|
+
try {
|
|
14
|
+
return existsSync(path) && statSync(path).size >= REAL_BUN_MIN_BYTES;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/lib/bun-runtime.ts
CHANGED
|
@@ -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 {
|
|
15
|
-
import {
|
|
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
|
-
|
|
51
|
+
const resolved = resolve(value);
|
|
52
|
+
return isRealBunBinary(resolved) ? resolved : null;
|
|
67
53
|
}
|
|
68
54
|
|
|
69
55
|
export function durableBunRuntime(): DurableBunRuntime {
|
|
@@ -130,13 +130,20 @@ function registryAllowsPrivateNetwork(name: string): boolean {
|
|
|
130
130
|
return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
|
|
131
131
|
}
|
|
132
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
|
+
|
|
133
141
|
export function providerDestinationConfigError(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): string | null {
|
|
134
142
|
const assessment = assessDestination(provider.baseUrl);
|
|
135
143
|
if (!assessment) return null;
|
|
136
144
|
if (assessment.kind === "public" || assessment.kind === "hostname") return null;
|
|
137
145
|
if (assessment.kind === "metadata") return "baseUrl targets a blocked metadata endpoint";
|
|
138
|
-
if (
|
|
139
|
-
if (provider.allowPrivateNetwork === true) return null;
|
|
146
|
+
if (providerAllowsPrivateNetwork(name, provider)) return null;
|
|
140
147
|
return `baseUrl points to a ${assessment.detail}; set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
|
|
141
148
|
}
|
|
142
149
|
|
|
@@ -174,7 +181,7 @@ export async function providerDestinationResolvedError(
|
|
|
174
181
|
if (!hostname || isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
175
182
|
return null; // literals and localhost are fully handled by the sync path
|
|
176
183
|
}
|
|
177
|
-
if (
|
|
184
|
+
if (providerAllowsPrivateNetwork(name, provider)) return null;
|
|
178
185
|
let addresses: { address: string }[];
|
|
179
186
|
try {
|
|
180
187
|
addresses = await lookup(hostname, { all: true, verbatim: true });
|
|
@@ -2,6 +2,7 @@ import type { OcxProviderConfig } from "../types";
|
|
|
2
2
|
import {
|
|
3
3
|
assessUrlDestination,
|
|
4
4
|
DestinationDnsResolutionError,
|
|
5
|
+
providerAllowsPrivateNetwork,
|
|
5
6
|
providerDestinationConfigError,
|
|
6
7
|
resolvePublicAddresses,
|
|
7
8
|
} from "./destination-policy";
|
|
@@ -116,7 +117,8 @@ export async function providerOutboundGet(
|
|
|
116
117
|
if (assessment?.kind === "metadata" || assessment?.kind === "link-local" || assessment?.kind === "unspecified") {
|
|
117
118
|
throw new ProviderOutboundPolicyError(`provider URL targets ${assessment.detail}`);
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
+
const allowPrivate = providerAllowsPrivateNetwork(name, provider);
|
|
121
|
+
if (!allowPrivate) {
|
|
120
122
|
const destinationError = providerDestinationConfigError(name, {
|
|
121
123
|
baseUrl: url,
|
|
122
124
|
allowPrivateNetwork: false,
|
|
@@ -129,11 +131,12 @@ export async function providerOutboundGet(
|
|
|
129
131
|
const proxyConfigured = configuredProxyFor();
|
|
130
132
|
const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
|
|
131
133
|
const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
|
|
134
|
+
const allowPrivate = providerAllowsPrivateNetwork(name, provider);
|
|
132
135
|
let resolved: Awaited<ReturnType<typeof resolvePublicAddresses>>;
|
|
133
136
|
try {
|
|
134
137
|
resolved = await resolveAddresses(url, {
|
|
135
138
|
context: "provider URL",
|
|
136
|
-
allowPrivateNetwork:
|
|
139
|
+
allowPrivateNetwork: allowPrivate,
|
|
137
140
|
});
|
|
138
141
|
} catch (error) {
|
|
139
142
|
const dnsResolutionFailed = error instanceof DestinationDnsResolutionError
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow-call intercept source models.
|
|
3
|
+
*
|
|
4
|
+
* Codex's hard-coded helper model is not stable across client versions: it was
|
|
5
|
+
* `gpt-5.4-mini` up to 0.144.x and became `gpt-5.6-luna` in 0.145.0. The
|
|
6
|
+
* intercept therefore matches a prefix SET, and every surface that names the
|
|
7
|
+
* intercepted model (management API, GUI badges/tooltips, CLI) reads it from
|
|
8
|
+
* here instead of hard-coding a slug that goes stale on the next client bump.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.4-mini", "gpt-5.6-luna"] as const;
|
|
11
|
+
|
|
12
|
+
/** Normalize a persisted `sourceModels` override; falls back to the defaults. */
|
|
13
|
+
export function shadowSourceModels(configured?: unknown): string[] {
|
|
14
|
+
const configuredStrings = Array.isArray(configured)
|
|
15
|
+
? configured
|
|
16
|
+
.filter((v): v is string => typeof v === "string" && v.trim() !== "")
|
|
17
|
+
.map(v => v.trim())
|
|
18
|
+
: [];
|
|
19
|
+
return configuredStrings.length > 0 ? configuredStrings : [...DEFAULT_SHADOW_SOURCE_MODELS];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when `modelId` is one of Codex's helper/shadow source models.
|
|
24
|
+
* Routed ids (`provider/model`) are hard-excluded: a shadow call is always a
|
|
25
|
+
* bare native slug, and an explicit routed selection must never be hijacked.
|
|
26
|
+
*/
|
|
27
|
+
export function isShadowSourceModel(modelId: string, configured?: unknown): boolean {
|
|
28
|
+
if (modelId.includes("/")) return false;
|
|
29
|
+
return shadowSourceModels(configured).some(prefix => modelId.startsWith(prefix));
|
|
30
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed protection for the user's REAL OpenCodex home while tests run.
|
|
3
|
+
*
|
|
4
|
+
* A management-route unit test once passed an in-memory fixture config to a handler
|
|
5
|
+
* that persisted it through the process-global writer, replacing a live 41KB,
|
|
6
|
+
* ten-provider `~/.opencodex/config.json` with an 874-byte fixture on a real machine.
|
|
7
|
+
* Credentials survived only because the store files are separate; the providers were
|
|
8
|
+
* recoverable only because an unrelated backup snapshot happened to exist.
|
|
9
|
+
* (devlog `_plan/260730_codex_rs_upstream_v2_live_handoff/070`.)
|
|
10
|
+
*
|
|
11
|
+
* Two properties matter more than breadth here:
|
|
12
|
+
*
|
|
13
|
+
* 1. It must be INERT in production. Guessing "am I a test?" from ecosystem variables
|
|
14
|
+
* like NODE_ENV would brick `NODE_ENV=test ocx ...` for a user who did nothing
|
|
15
|
+
* wrong — worse than the bug it prevents. Arming requires OCX_TEST_HOME_GUARD=1,
|
|
16
|
+
* which only this repository's test preload sets.
|
|
17
|
+
* 2. It must fail CLOSED for code nobody has written yet. So it denies ONE path — the
|
|
18
|
+
* captured production home — instead of allow-listing known-good test directories.
|
|
19
|
+
* An allowlist would have to be opted into, and the test that forgets is exactly
|
|
20
|
+
* how this incident happened.
|
|
21
|
+
*/
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
24
|
+
import { realpathSync } from "node:fs";
|
|
25
|
+
|
|
26
|
+
const GUARD_ENV = "OCX_TEST_HOME_GUARD";
|
|
27
|
+
/**
|
|
28
|
+
* Set by `scripts/test.ts` to the ORIGINAL home before it hands the child a rewritten
|
|
29
|
+
* HOME. On that path `homedir()` already points at the sandbox by the time this module
|
|
30
|
+
* loads, so the true home is only knowable from this hand-off.
|
|
31
|
+
*/
|
|
32
|
+
const REAL_HOME_ENV = "OCX_REAL_HOME";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve symlinks so two spellings of one location compare equal — macOS hands out
|
|
36
|
+
* `/var/folders/...` whose realpath is `/private/var/folders/...` — and so a path that
|
|
37
|
+
* merely *points* at the protected home cannot slip past a string comparison. A path
|
|
38
|
+
* that does not exist yet canonicalizes through its nearest existing ancestor, which is
|
|
39
|
+
* the common case for a config file about to be created.
|
|
40
|
+
*/
|
|
41
|
+
function canonicalize(path: string): string {
|
|
42
|
+
let current = resolve(path);
|
|
43
|
+
const unresolved: string[] = [];
|
|
44
|
+
for (;;) {
|
|
45
|
+
try {
|
|
46
|
+
return join(realpathSync.native(current), ...unresolved.reverse());
|
|
47
|
+
} catch {
|
|
48
|
+
const parent = dirname(current);
|
|
49
|
+
if (parent === current) return resolve(path);
|
|
50
|
+
unresolved.push(current.slice(parent.length + 1));
|
|
51
|
+
current = parent;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Captured ONCE at module load, before any harness replaces HOME/USERPROFILE. Reading
|
|
58
|
+
* `homedir()` later would return the sandbox and leave the real home unprotected — the
|
|
59
|
+
* guard would be perfectly inverted while its tests still looked green.
|
|
60
|
+
*/
|
|
61
|
+
const PROTECTED_HOME = canonicalize(
|
|
62
|
+
join(process.env[REAL_HOME_ENV]?.trim() || homedir(), ".opencodex"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
/** The production home this process protects. Exported for the guard's own tests. */
|
|
66
|
+
export function protectedHomeForTests(): string {
|
|
67
|
+
return PROTECTED_HOME;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isTestHomeGuardArmed(): boolean {
|
|
71
|
+
return process.env[GUARD_ENV] === "1";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Throw when an armed test process is about to write the real OpenCodex home.
|
|
76
|
+
*
|
|
77
|
+
* Call FIRST inside a writer, before any mkdir/chmod/write, so a rejected write leaves
|
|
78
|
+
* nothing behind. Silent no-op when disarmed (production) or when `dir` is any other
|
|
79
|
+
* location, including a suite's own `mkdtemp` fixture — no registration required, which
|
|
80
|
+
* is what keeps the 54 existing suites that write config working untouched.
|
|
81
|
+
*/
|
|
82
|
+
export function assertNotRealHomeUnderTest(dir: string): void {
|
|
83
|
+
if (!isTestHomeGuardArmed()) return;
|
|
84
|
+
if (canonicalize(dir) !== PROTECTED_HOME) return;
|
|
85
|
+
throw new Error(
|
|
86
|
+
`refusing to write the real OpenCodex home (${PROTECTED_HOME}) from a test process. `
|
|
87
|
+
+ "Point OPENCODEX_HOME at a temp directory for this test, or inject persistence "
|
|
88
|
+
+ "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).",
|
|
89
|
+
);
|
|
90
|
+
}
|
package/src/lib/win-exec.ts
CHANGED
|
@@ -44,8 +44,18 @@ export function resolveWindowsCommand(command: string, deps: ResolveDeps = {}):
|
|
|
44
44
|
if (win32.extname(command) || command.includes("\\") || command.includes("/") || win32.isAbsolute(command)) {
|
|
45
45
|
return command;
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Windows environment variables are case-insensitive, and a spawned child can
|
|
48
|
+
// arrive with `Path`, `PATH`, or both depending on who built its env. Reading
|
|
49
|
+
// only two fixed spellings silently resolved against the wrong list once a
|
|
50
|
+
// caller added a second casing, so match however the key is spelled.
|
|
51
|
+
const lookup = (name: string): string | undefined => {
|
|
52
|
+
const direct = env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()];
|
|
53
|
+
if (direct !== undefined) return direct;
|
|
54
|
+
const key = Object.keys(env).find(k => k.toLowerCase() === name.toLowerCase());
|
|
55
|
+
return key ? env[key] : undefined;
|
|
56
|
+
};
|
|
57
|
+
const exts = (lookup("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
|
|
58
|
+
for (const dir of (lookup("PATH") ?? "").split(win32.delimiter).filter(Boolean)) {
|
|
49
59
|
for (const ext of exts) {
|
|
50
60
|
const candidate = win32.join(dir, command + ext.toLowerCase());
|
|
51
61
|
if (exists(candidate)) return candidate;
|
package/src/oauth/index.ts
CHANGED
|
@@ -14,7 +14,8 @@ import { loginCursor, refreshCursorToken } from "./cursor";
|
|
|
14
14
|
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
|
|
15
15
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
16
16
|
import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
|
|
17
|
-
import { effectiveGoogleMode, getProviderRegistryEntry } from "../providers/registry";
|
|
17
|
+
import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry";
|
|
18
|
+
import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
18
19
|
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
19
20
|
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
|
|
20
21
|
import { logOAuthEvent } from "./log";
|
|
@@ -467,6 +468,22 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
|
|
|
467
468
|
return resolveEnvValue(prov.apiKey);
|
|
468
469
|
}
|
|
469
470
|
|
|
471
|
+
function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConfig): OcxProviderConfig {
|
|
472
|
+
const entry = getProviderRegistryEntry(providerName);
|
|
473
|
+
if (
|
|
474
|
+
prov.authMode !== "oauth"
|
|
475
|
+
|| entry?.authKind !== "oauth"
|
|
476
|
+
|| entry.allowBaseUrlOverride === true
|
|
477
|
+
|| /\{[^}]*\}/.test(entry.baseUrl)
|
|
478
|
+
|| !providerMatchesRegistryTransport(providerName, prov)
|
|
479
|
+
) {
|
|
480
|
+
return prov;
|
|
481
|
+
}
|
|
482
|
+
// Normal routing pins fixed OAuth presets before adapter-specific transport resolution.
|
|
483
|
+
// Discovery must do the same so a stale or modified config baseUrl never receives a token.
|
|
484
|
+
return { ...prov, adapter: entry.adapter, baseUrl: entry.baseUrl };
|
|
485
|
+
}
|
|
486
|
+
|
|
470
487
|
/**
|
|
471
488
|
* Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the
|
|
472
489
|
* LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models`
|
|
@@ -479,20 +496,27 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
|
|
|
479
496
|
* response.
|
|
480
497
|
*/
|
|
481
498
|
export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
|
|
499
|
+
const transportSeed = modelDiscoveryTransportSeed(providerName, prov);
|
|
482
500
|
const effectiveProvider = resolveProviderTransport(
|
|
483
501
|
providerName,
|
|
484
|
-
|
|
502
|
+
transportSeed,
|
|
485
503
|
undefined,
|
|
486
504
|
providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined,
|
|
487
505
|
);
|
|
488
506
|
const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
|
|
507
|
+
const discoveryUrl = (defaultUrl: string): string => resolveProviderModelDiscoveryUrl(
|
|
508
|
+
providerName,
|
|
509
|
+
prov,
|
|
510
|
+
effectiveProvider.baseUrl,
|
|
511
|
+
defaultUrl,
|
|
512
|
+
);
|
|
489
513
|
if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
|
|
490
514
|
// Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
|
|
491
515
|
// models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
|
|
492
516
|
// enough to list everything without a pageToken loop. Vertex/antigravity keep the
|
|
493
517
|
// generic branch (they fall back to their static model lists).
|
|
494
518
|
if (apiKey) headers["x-goog-api-key"] = apiKey;
|
|
495
|
-
return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000
|
|
519
|
+
return { url: discoveryUrl(`${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`), headers };
|
|
496
520
|
}
|
|
497
521
|
if (effectiveProvider.adapter === "anthropic") {
|
|
498
522
|
const base = effectiveProvider.baseUrl.replace(/\/v1\/?$/, "");
|
|
@@ -504,10 +528,10 @@ export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | und
|
|
|
504
528
|
if (effectiveProvider.apiKeyTransport === "bearer") headers["Authorization"] = `Bearer ${apiKey}`;
|
|
505
529
|
else headers["x-api-key"] = apiKey;
|
|
506
530
|
}
|
|
507
|
-
return { url: `${base}/v1/models?limit=1000
|
|
531
|
+
return { url: discoveryUrl(`${base}/v1/models?limit=1000`), headers };
|
|
508
532
|
}
|
|
509
533
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
510
|
-
return { url: `${effectiveProvider.baseUrl}/models
|
|
534
|
+
return { url: discoveryUrl(`${effectiveProvider.baseUrl}/models`), headers };
|
|
511
535
|
}
|
|
512
536
|
|
|
513
537
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxProviderConfig } from "../types";
|
|
2
2
|
import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive";
|
|
3
|
+
import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can
|
|
@@ -45,7 +46,11 @@ function anthropicKeyValidationHeaders(provider: Pick<KeyLoginProvider, "apiKeyT
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** Best-effort key validation. Returns true/false/unknown; never persists the key itself. */
|
|
48
|
-
export async function validateApiKey(
|
|
49
|
+
export async function validateApiKey(
|
|
50
|
+
providerName: string,
|
|
51
|
+
provider: KeyLoginProvider,
|
|
52
|
+
key: string,
|
|
53
|
+
): Promise<boolean | "unknown"> {
|
|
49
54
|
try {
|
|
50
55
|
if (provider.adapter === "anthropic") {
|
|
51
56
|
const base = provider.baseUrl.replace(/\/v1\/?$/, "");
|
|
@@ -57,6 +62,7 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P
|
|
|
57
62
|
max_tokens: 1,
|
|
58
63
|
messages: [{ role: "user", content: "ping" }],
|
|
59
64
|
}),
|
|
65
|
+
redirect: "error",
|
|
60
66
|
signal: AbortSignal.timeout(8000),
|
|
61
67
|
});
|
|
62
68
|
if (res.ok) return true;
|
|
@@ -69,6 +75,7 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P
|
|
|
69
75
|
// documented x-goog-api-key header instead (pageSize=1 — validation only needs a 200).
|
|
70
76
|
const res = await fetch(`${provider.baseUrl}/v1beta/models?pageSize=1`, {
|
|
71
77
|
headers: { "x-goog-api-key": key },
|
|
78
|
+
redirect: "error",
|
|
72
79
|
signal: AbortSignal.timeout(8000),
|
|
73
80
|
});
|
|
74
81
|
if (res.ok) return true;
|
|
@@ -76,8 +83,20 @@ export async function validateApiKey(provider: KeyLoginProvider, key: string): P
|
|
|
76
83
|
return "unknown";
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
const
|
|
86
|
+
const configuredProvider: OcxProviderConfig = {
|
|
87
|
+
adapter: provider.adapter,
|
|
88
|
+
baseUrl: provider.baseUrl,
|
|
89
|
+
authMode: "key",
|
|
90
|
+
};
|
|
91
|
+
const modelsUrl = resolveProviderModelDiscoveryUrl(
|
|
92
|
+
providerName,
|
|
93
|
+
configuredProvider,
|
|
94
|
+
provider.baseUrl,
|
|
95
|
+
`${provider.baseUrl}/models`,
|
|
96
|
+
);
|
|
97
|
+
const res = await fetch(modelsUrl, {
|
|
80
98
|
headers: { Authorization: `Bearer ${key}` },
|
|
99
|
+
redirect: "error",
|
|
81
100
|
signal: AbortSignal.timeout(8000),
|
|
82
101
|
});
|
|
83
102
|
if (res.ok) return true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { isAbsolute, join, posix, win32 } from "node:path";
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
6
6
|
|
|
7
7
|
const DEFAULT_EXPIRES_MS = 3600_000;
|
|
@@ -39,7 +39,14 @@ export type KiroDiagnosticStatus =
|
|
|
39
39
|
| "registration_found";
|
|
40
40
|
|
|
41
41
|
export interface KiroImportDiagnostic {
|
|
42
|
-
location:
|
|
42
|
+
location:
|
|
43
|
+
| "kiro-creds-file"
|
|
44
|
+
| "kiro-cli-db-env"
|
|
45
|
+
| "kiro-cli-data"
|
|
46
|
+
| "kiro-cli-linux-data"
|
|
47
|
+
| "kiro-cli-windows-data"
|
|
48
|
+
| "amazon-q-data"
|
|
49
|
+
| "kiro-sso-cache";
|
|
43
50
|
status: KiroDiagnosticStatus;
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -122,14 +129,56 @@ function jsonCredentialPaths(): string[] {
|
|
|
122
129
|
.map(expandPath);
|
|
123
130
|
}
|
|
124
131
|
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
export type KiroCliNativeLocation = "kiro-cli-data" | "kiro-cli-linux-data" | "kiro-cli-windows-data";
|
|
133
|
+
|
|
134
|
+
export interface KiroCliNativeInputs {
|
|
135
|
+
env: Record<string, string | undefined>;
|
|
136
|
+
platform: NodeJS.Platform;
|
|
137
|
+
home: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Native kiro-cli session stores, as a pure function of (env, platform, home).
|
|
142
|
+
*
|
|
143
|
+
* Pure + parameterized following `src/claude/desktop-3p-paths.ts`: `process.platform` is stubbable
|
|
144
|
+
* in this repo, but `os.platform()` does NOT follow it under Bun, so a pure resolver is the reliable
|
|
145
|
+
* way to exercise the win32 branch (and its env fallbacks) on a macOS/Linux host.
|
|
146
|
+
*
|
|
147
|
+
* Windows (issue #710): the official installer stores the auth DB at
|
|
148
|
+
* `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`. When LOCALAPPDATA is unset or blank, fall back to
|
|
149
|
+
* `%USERPROFILE%\AppData\Local`, then to the injected platform-native home. Deliberately NOT
|
|
150
|
+
* `userHome()`: that is `HOME || homedir()` and Windows shells (Git Bash / MSYS / CI) routinely
|
|
151
|
+
* export a POSIX-style `HOME`, which would point this at a non-native path.
|
|
152
|
+
*
|
|
153
|
+
* One entry per platform on purpose: this list also drives forced-login snapshot/rollback, so it
|
|
154
|
+
* must name the database the LOCAL kiro-cli actually mutates, never a foreign platform's path.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveKiroCliNativeSessionEntries(
|
|
157
|
+
inputs: KiroCliNativeInputs,
|
|
158
|
+
): Array<{ location: KiroCliNativeLocation; path: string }> {
|
|
159
|
+
const { env, platform, home } = inputs;
|
|
160
|
+
if (platform === "win32") {
|
|
161
|
+
const base = env.LOCALAPPDATA?.trim()
|
|
162
|
+
|| (env.USERPROFILE?.trim() ? win32.join(env.USERPROFILE.trim(), "AppData", "Local") : "")
|
|
163
|
+
|| win32.join(home, "AppData", "Local");
|
|
164
|
+
return [{ location: "kiro-cli-windows-data", path: win32.join(base, "Kiro-Cli", "data.sqlite3") }];
|
|
165
|
+
}
|
|
166
|
+
if (platform === "darwin") {
|
|
167
|
+
return [{ location: "kiro-cli-data", path: posix.join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3") }];
|
|
168
|
+
}
|
|
169
|
+
return [{ location: "kiro-cli-linux-data", path: posix.join(home, ".local", "share", "kiro-cli", "data.sqlite3") }];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function nativeKiroCliSessionEntries(): Array<{ location: KiroCliNativeLocation; path: string }> {
|
|
127
173
|
// Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks
|
|
128
174
|
// (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback.
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
175
|
+
return resolveKiroCliNativeSessionEntries({
|
|
176
|
+
env: process.env,
|
|
177
|
+
platform: process.platform,
|
|
178
|
+
// POSIX keeps HOME-first userHome() so existing HOME-based fixtures still resolve; win32 prefers
|
|
179
|
+
// LOCALAPPDATA/USERPROFILE and only falls back to this platform-native home.
|
|
180
|
+
home: process.platform === "win32" ? homedir() : userHome(),
|
|
181
|
+
});
|
|
133
182
|
}
|
|
134
183
|
|
|
135
184
|
function sqliteEntries(): Array<{ location: KiroImportDiagnostic["location"]; path: string }> {
|
package/src/oauth/kiro.ts
CHANGED
|
@@ -289,7 +289,8 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions
|
|
|
289
289
|
"Kiro CLI session could not be backed up, so OCX will not sign it out. " +
|
|
290
290
|
"Repair or remove the unreadable kiro-cli credential database " +
|
|
291
291
|
"(usually `~/.local/share/kiro-cli/data.sqlite3` or " +
|
|
292
|
-
"`~/Library/Application Support/kiro-cli/data.sqlite3
|
|
292
|
+
"`~/Library/Application Support/kiro-cli/data.sqlite3`, or " +
|
|
293
|
+
"`%LOCALAPPDATA%\\Kiro-Cli\\data.sqlite3` on Windows), " +
|
|
293
294
|
"unset KIROCLI_DB_PATH / KIRO_CLI_DB_FILE if set for import-only overrides, then retry.",
|
|
294
295
|
);
|
|
295
296
|
}
|
package/src/oauth/login-cli.ts
CHANGED
|
@@ -135,7 +135,7 @@ async function handleKeyLogin(name: string): Promise<void> {
|
|
|
135
135
|
process.exit(1);
|
|
136
136
|
}
|
|
137
137
|
process.stdout.write(" validating… ");
|
|
138
|
-
const valid = await validateApiKey({ ...def, baseUrl }, key);
|
|
138
|
+
const valid = await validateApiKey(name, { ...def, baseUrl }, key);
|
|
139
139
|
console.log(valid === true ? "valid ✅" : valid === false ? "INVALID ❌" : "couldn't validate (may still work)");
|
|
140
140
|
if (valid === false) {
|
|
141
141
|
console.error("Provider rejected the key. Not saved.");
|
package/src/oauth/store.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
19
19
|
import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
22
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
22
23
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
23
24
|
import { validateCopilotApiBaseUrl } from "./github-copilot";
|
|
24
25
|
import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
|
|
@@ -131,6 +132,7 @@ export function peekAuthStore(): AuthStore {
|
|
|
131
132
|
|
|
132
133
|
function persist(store: AuthStore): void {
|
|
133
134
|
const dir = getConfigDir();
|
|
135
|
+
assertNotRealHomeUnderTest(dir);
|
|
134
136
|
if (!existsSync(dir)) {
|
|
135
137
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
136
138
|
} else {
|
package/src/providers/derive.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CodexAccountMode, OcxProviderConfig } from "../types";
|
|
2
|
-
import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "./registry";
|
|
2
|
+
import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, type ProviderRegistryEntry } from "./registry";
|
|
3
3
|
|
|
4
4
|
export interface DerivedKeyLoginProvider {
|
|
5
5
|
label: string;
|
|
@@ -223,7 +223,7 @@ export function deriveProviderPresets(): DerivedProviderPreset[] {
|
|
|
223
223
|
|
|
224
224
|
export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig): void {
|
|
225
225
|
const entry = PROVIDER_REGISTRY.find(row => row.id === name);
|
|
226
|
-
if (!entry) return;
|
|
226
|
+
if (!entry || !providerMatchesRegistryTransport(name, prov)) return;
|
|
227
227
|
const seed = providerConfigSeed(entry);
|
|
228
228
|
if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport;
|
|
229
229
|
if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel;
|