@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.
Files changed (73) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BDjpkcRN.js +67 -0
  3. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/discovery.ts +4 -1
  7. package/src/adapters/cursor/effort-map.ts +3 -0
  8. package/src/adapters/kiro.ts +15 -1
  9. package/src/claude/alias.ts +94 -14
  10. package/src/claude/outbound.ts +6 -3
  11. package/src/cli/catalog-prewarm.ts +24 -0
  12. package/src/cli/claude.ts +32 -7
  13. package/src/cli/doctor.ts +48 -1
  14. package/src/cli/index.ts +5 -0
  15. package/src/cli/interactive-confirm.ts +5 -1
  16. package/src/cli/star-prompt.ts +26 -4
  17. package/src/cli/v2.ts +10 -1
  18. package/src/codex/account-store.ts +2 -0
  19. package/src/codex/catalog/bundled.ts +9 -2
  20. package/src/codex/catalog/parsing.ts +26 -1
  21. package/src/codex/catalog/provider-fetch.ts +240 -82
  22. package/src/codex/catalog/sync.ts +27 -5
  23. package/src/codex/catalog.ts +1 -1
  24. package/src/codex/features.ts +524 -5
  25. package/src/codex/quota.ts +77 -2
  26. package/src/codex/runtime.ts +10 -1
  27. package/src/config.ts +8 -0
  28. package/src/generated/jawcode-model-metadata.ts +12 -12
  29. package/src/github/star-state.ts +191 -0
  30. package/src/lib/bun-binary-validator.d.mts +3 -0
  31. package/src/lib/bun-binary-validator.mjs +18 -0
  32. package/src/lib/bun-runtime.ts +6 -20
  33. package/src/lib/destination-policy.ts +10 -3
  34. package/src/lib/provider-outbound.ts +5 -2
  35. package/src/lib/shadow-call.ts +30 -0
  36. package/src/lib/test-home-guard.ts +90 -0
  37. package/src/lib/win-exec.ts +12 -2
  38. package/src/oauth/index.ts +29 -5
  39. package/src/oauth/key-providers.ts +21 -2
  40. package/src/oauth/kiro-credentials.ts +57 -8
  41. package/src/oauth/kiro.ts +2 -1
  42. package/src/oauth/login-cli.ts +1 -1
  43. package/src/oauth/store.ts +2 -0
  44. package/src/providers/derive.ts +2 -2
  45. package/src/providers/model-discovery.ts +356 -0
  46. package/src/providers/registry.ts +114 -0
  47. package/src/router.ts +5 -3
  48. package/src/server/auth-cors.ts +4 -2
  49. package/src/server/live.ts +75 -25
  50. package/src/server/management/agent-settings-routes.ts +78 -4
  51. package/src/server/management/config-routes.ts +19 -7
  52. package/src/server/management/context.ts +11 -1
  53. package/src/server/management/model-routes.ts +46 -13
  54. package/src/server/management/provider-routes.ts +44 -9
  55. package/src/server/management/shared.ts +2 -2
  56. package/src/server/management/sidebar-routes.ts +39 -0
  57. package/src/server/management-api.ts +3 -1
  58. package/src/server/responses/core.ts +31 -20
  59. package/src/server/responses/upstream-error.ts +48 -0
  60. package/src/server/startup-action-control.ts +30 -14
  61. package/src/service.ts +237 -19
  62. package/src/storage/policy-job.ts +26 -5
  63. package/src/storage/restore-job.ts +16 -5
  64. package/src/storage/worker-lifecycle.ts +81 -0
  65. package/src/tray/windows.ts +32 -4
  66. package/src/types.ts +11 -0
  67. package/src/update/badge.ts +72 -0
  68. package/src/update/job.ts +8 -4
  69. package/src/usage/expected-prices.ts +6 -5
  70. package/src/usage/log.ts +8 -0
  71. package/src/web-search/loop.ts +57 -16
  72. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  73. 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
+ }
@@ -11,15 +11,13 @@
11
11
  * back to `process.execPath` (which is itself Bun when run via `bun src/cli/index.ts`).
12
12
  */
13
13
  import { createRequire } from "node:module";
14
- import { existsSync, statSync } from "node:fs";
15
- import { dirname, join } from "node:path";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { isRealBunBinary } from "./bun-binary-validator.mjs";
16
+
17
+ export { isRealBunBinary };
16
18
 
17
19
  const require = createRequire(import.meta.url);
18
20
 
19
- // The `bun` package leaves a tiny ASCII placeholder at bin/bun.exe until its
20
- // postinstall downloads the real ~60MB binary; reject the stub by size so we
21
- // never bake a non-executable path into durable artifacts.
22
- const REAL_BUN_MIN_BYTES = 1_000_000;
23
21
  const BUN_OVERRIDE_ENV = "OPENCODEX_BUN_PATH";
24
22
 
25
23
  export type DurableBunRuntime = {
@@ -28,19 +26,6 @@ export type DurableBunRuntime = {
28
26
  overrideEnv: typeof BUN_OVERRIDE_ENV;
29
27
  };
30
28
 
31
- /**
32
- * True only for a real, downloaded Bun binary — not the ~450-byte ASCII
33
- * placeholder stub left by `--ignore-scripts` / pnpm. A size gate cleanly
34
- * separates the two on every platform (real binary is tens of MB).
35
- */
36
- export function isRealBunBinary(path: string): boolean {
37
- try {
38
- return existsSync(path) && statSync(path).size >= REAL_BUN_MIN_BYTES;
39
- } catch {
40
- return false;
41
- }
42
- }
43
-
44
29
  /**
45
30
  * Absolute path to the bundled Bun binary, or null if the `bun` dependency is
46
31
  * not installed/resolvable (or only the un-downloaded placeholder is present).
@@ -63,7 +48,8 @@ export function bundledBunPath(): string | null {
63
48
  export function overrideBunPath(): string | null {
64
49
  const value = process.env[BUN_OVERRIDE_ENV]?.trim();
65
50
  if (!value) return null;
66
- return isRealBunBinary(value) ? value : null;
51
+ const resolved = resolve(value);
52
+ return isRealBunBinary(resolved) ? resolved : null;
67
53
  }
68
54
 
69
55
  export function durableBunRuntime(): DurableBunRuntime {
@@ -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 (registryAllowsPrivateNetwork(name)) return null;
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 (registryAllowsPrivateNetwork(name) || provider.allowPrivateNetwork === true) return null;
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
- if (!provider.allowPrivateNetwork) {
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: provider.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
+ }
@@ -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
- const exts = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean);
48
- for (const dir of (env.PATH ?? env.Path ?? "").split(win32.delimiter).filter(Boolean)) {
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;
@@ -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
- prov,
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`, headers };
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`, headers };
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`, headers };
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(provider: KeyLoginProvider, key: string): Promise<boolean | "unknown"> {
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 res = await fetch(`${provider.baseUrl}/models`, {
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: "kiro-creds-file" | "kiro-cli-db-env" | "kiro-cli-data" | "kiro-cli-linux-data" | "amazon-q-data" | "kiro-sso-cache";
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
- function nativeKiroCliSessionEntries(): Array<{ location: "kiro-cli-data" | "kiro-cli-linux-data"; path: string }> {
126
- const home = userHome();
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
- { location: "kiro-cli-data", path: join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3") },
131
- { location: "kiro-cli-linux-data", path: join(home, ".local", "share", "kiro-cli", "data.sqlite3") },
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
  }
@@ -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.");
@@ -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 {
@@ -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;