@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731
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-BHsKRFh9.css +1 -0
- package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +42 -7
- 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/adapters/openai-chat.ts +55 -4
- 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-desktop.ts +2 -2
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/init.ts +129 -102
- 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/metadata.ts +6 -0
- 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 +3 -3
- 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 +21 -3
- package/src/lib/provider-outbound.ts +8 -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/lib/winsw.ts +6 -0
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +129 -9
- package/src/oauth/kiro.ts +15 -3
- 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/free-directory.ts +4 -1
- 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/index.ts +3 -3
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +82 -8
- package/src/server/management/config-routes.ts +24 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +61 -14
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +18 -5
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/proxy-liveness.ts +9 -2
- 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 +395 -31
- 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 +86 -13
- package/src/types.ts +16 -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,31 @@ function registryAllowsPrivateNetwork(name: string): boolean {
|
|
|
130
130
|
return getProviderRegistryEntry(name)?.allowPrivateNetworkByDefault === true;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Whether a provider may reach loopback/private addresses.
|
|
135
|
+
*
|
|
136
|
+
* Two sources, and both have to be consulted at every boundary: the operator's explicit
|
|
137
|
+
* `allowPrivateNetwork`, and the registry's `allowPrivateNetworkByDefault` for entries that are
|
|
138
|
+
* local BY DEFINITION (Ollama, vLLM, LM Studio, LiteLLM). Config validation already read both;
|
|
139
|
+
* outbound discovery read only the first, so a stock Ollama entry passed validation and was then
|
|
140
|
+
* refused at the fetch (#758).
|
|
141
|
+
*
|
|
142
|
+
* This grants nothing new. Metadata, link-local and unspecified destinations are rejected before
|
|
143
|
+
* this is consulted, and a provider without either source still cannot reach a private address.
|
|
144
|
+
*/
|
|
145
|
+
export function providerAllowsPrivateNetwork(
|
|
146
|
+
name: string,
|
|
147
|
+
provider: Pick<OcxProviderConfig, "allowPrivateNetwork">,
|
|
148
|
+
): boolean {
|
|
149
|
+
return provider.allowPrivateNetwork === true || registryAllowsPrivateNetwork(name);
|
|
150
|
+
}
|
|
151
|
+
|
|
133
152
|
export function providerDestinationConfigError(name: string, provider: Pick<OcxProviderConfig, "baseUrl" | "allowPrivateNetwork">): string | null {
|
|
134
153
|
const assessment = assessDestination(provider.baseUrl);
|
|
135
154
|
if (!assessment) return null;
|
|
136
155
|
if (assessment.kind === "public" || assessment.kind === "hostname") return null;
|
|
137
156
|
if (assessment.kind === "metadata") return "baseUrl targets a blocked metadata endpoint";
|
|
138
|
-
if (
|
|
139
|
-
if (provider.allowPrivateNetwork === true) return null;
|
|
157
|
+
if (providerAllowsPrivateNetwork(name, provider)) return null;
|
|
140
158
|
return `baseUrl points to a ${assessment.detail}; set allowPrivateNetwork:true only for intentionally local/self-hosted providers`;
|
|
141
159
|
}
|
|
142
160
|
|
|
@@ -174,7 +192,7 @@ export async function providerDestinationResolvedError(
|
|
|
174
192
|
if (!hostname || isIP(hostname) !== 0 || hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
175
193
|
return null; // literals and localhost are fully handled by the sync path
|
|
176
194
|
}
|
|
177
|
-
if (
|
|
195
|
+
if (providerAllowsPrivateNetwork(name, provider)) return null;
|
|
178
196
|
let addresses: { address: string }[];
|
|
179
197
|
try {
|
|
180
198
|
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,11 @@ 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
|
+
// Registry defaults count here too, not just the operator flag: a stock Ollama entry is
|
|
121
|
+
// local by definition and previously passed config validation only to be refused at the
|
|
122
|
+
// fetch (#758). Metadata/link-local/unspecified were already rejected above.
|
|
123
|
+
const allowPrivate = providerAllowsPrivateNetwork(name, provider);
|
|
124
|
+
if (!allowPrivate) {
|
|
120
125
|
const destinationError = providerDestinationConfigError(name, {
|
|
121
126
|
baseUrl: url,
|
|
122
127
|
allowPrivateNetwork: false,
|
|
@@ -129,11 +134,12 @@ export async function providerOutboundGet(
|
|
|
129
134
|
const proxyConfigured = configuredProxyFor();
|
|
130
135
|
const resolveAddresses = dependencies.resolveAddresses ?? resolvePublicAddresses;
|
|
131
136
|
const pinnedGet = dependencies.pinnedGet ?? pinnedHttpGet;
|
|
137
|
+
const allowPrivate = providerAllowsPrivateNetwork(name, provider);
|
|
132
138
|
let resolved: Awaited<ReturnType<typeof resolvePublicAddresses>>;
|
|
133
139
|
try {
|
|
134
140
|
resolved = await resolveAddresses(url, {
|
|
135
141
|
context: "provider URL",
|
|
136
|
-
allowPrivateNetwork:
|
|
142
|
+
allowPrivateNetwork: allowPrivate,
|
|
137
143
|
});
|
|
138
144
|
} catch (error) {
|
|
139
145
|
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/lib/winsw.ts
CHANGED
|
@@ -165,6 +165,12 @@ function runWinsw(args: string[]): string {
|
|
|
165
165
|
|
|
166
166
|
/** `install /p` prompts for the service-account password on the console — stdin must be inherited. */
|
|
167
167
|
function runWinswInteractive(args: string[]): void {
|
|
168
|
+
if (!process.stdin.isTTY) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
"WinSW install requires an interactive console to prompt for the service account password. "
|
|
171
|
+
+ "Run `ocx service install --native` from an elevated Command Prompt or PowerShell window, not a hidden or piped session.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
168
174
|
execFileSync(winswExePath(), args, { stdio: "inherit" });
|
|
169
175
|
}
|
|
170
176
|
|
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
|
-
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, openSync, readFileSync, renameSync, rmSync, statSync, 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,127 @@ 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
|
+
/**
|
|
173
|
+
* Resolve the absolute kiro-cli executable for spawn/login helpers.
|
|
174
|
+
*
|
|
175
|
+
* Pure + parameterized like `resolveKiroCliNativeSessionEntries` so Windows install layouts can be
|
|
176
|
+
* covered from any host. PATH remains the first choice; only when bare `kiro-cli` is missing do we
|
|
177
|
+
* fall back to the platform-native install directories next to the session database.
|
|
178
|
+
*
|
|
179
|
+
* Windows: official MSI installs to `C:\Program Files\Kiro-Cli\kiro-cli.exe`, while some local
|
|
180
|
+
* installs keep the binary next to `%LOCALAPPDATA%\Kiro-Cli\data.sqlite3`.
|
|
181
|
+
* macOS/Linux: prefer PATH, then the usual user-local bin directories.
|
|
182
|
+
*/
|
|
183
|
+
export function resolveKiroCliExecutable(
|
|
184
|
+
inputs: KiroCliNativeInputs & {
|
|
185
|
+
pathEntries?: string[];
|
|
186
|
+
exists?: (path: string) => boolean;
|
|
187
|
+
isFile?: (path: string) => boolean;
|
|
188
|
+
},
|
|
189
|
+
): string {
|
|
190
|
+
const exists = inputs.exists ?? existsSync;
|
|
191
|
+
// A directory named `kiro-cli` on PATH satisfies existsSync and would then be handed to
|
|
192
|
+
// spawn(), which fails with EACCES at login instead of falling through to the next candidate.
|
|
193
|
+
// When a caller injects `exists` it owns the whole filesystem view, so the real stat would
|
|
194
|
+
// reject every synthetic path; such callers inject `isFile` too when they care about it.
|
|
195
|
+
const isFile = inputs.isFile ?? (inputs.exists ? () => true : ((path: string) => {
|
|
196
|
+
try {
|
|
197
|
+
return statSync(path).isFile();
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}));
|
|
202
|
+
const pathEntries = inputs.pathEntries
|
|
203
|
+
?? (inputs.env.PATH ?? inputs.env.Path ?? "").split(inputs.platform === "win32" ? ";" : ":")
|
|
204
|
+
.map(entry => entry.trim())
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
|
|
207
|
+
const pathCandidates = inputs.platform === "win32"
|
|
208
|
+
? pathEntries.flatMap(entry => [
|
|
209
|
+
win32.join(entry, "kiro-cli.exe"),
|
|
210
|
+
win32.join(entry, "kiro-cli"),
|
|
211
|
+
])
|
|
212
|
+
: pathEntries.map(entry => posix.join(entry, "kiro-cli"));
|
|
213
|
+
|
|
214
|
+
const installCandidates: string[] = [];
|
|
215
|
+
if (inputs.platform === "win32") {
|
|
216
|
+
const localBase = inputs.env.LOCALAPPDATA?.trim()
|
|
217
|
+
|| (inputs.env.USERPROFILE?.trim() ? win32.join(inputs.env.USERPROFILE.trim(), "AppData", "Local") : "")
|
|
218
|
+
|| win32.join(inputs.home, "AppData", "Local");
|
|
219
|
+
const programFiles = inputs.env["ProgramFiles"]?.trim() || "C:\\Program Files";
|
|
220
|
+
installCandidates.push(
|
|
221
|
+
win32.join(localBase, "Kiro-Cli", "kiro-cli.exe"),
|
|
222
|
+
win32.join(programFiles, "Kiro-Cli", "kiro-cli.exe"),
|
|
223
|
+
);
|
|
224
|
+
} else if (inputs.platform === "darwin") {
|
|
225
|
+
installCandidates.push(
|
|
226
|
+
posix.join(inputs.home, ".local", "bin", "kiro-cli"),
|
|
227
|
+
"/usr/local/bin/kiro-cli",
|
|
228
|
+
"/opt/homebrew/bin/kiro-cli",
|
|
229
|
+
);
|
|
230
|
+
} else {
|
|
231
|
+
installCandidates.push(
|
|
232
|
+
posix.join(inputs.home, ".local", "bin", "kiro-cli"),
|
|
233
|
+
"/usr/local/bin/kiro-cli",
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const candidate of [...pathCandidates, ...installCandidates]) {
|
|
238
|
+
if (exists(candidate) && isFile(candidate)) return candidate;
|
|
239
|
+
}
|
|
240
|
+
return inputs.platform === "win32" ? "kiro-cli.exe" : "kiro-cli";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function nativeKiroCliSessionEntries(): Array<{ location: KiroCliNativeLocation; path: string }> {
|
|
127
244
|
// Only the stores that `kiro-cli logout` / `kiro-cli login` themselves mutate. Import fallbacks
|
|
128
245
|
// (Amazon Q / SSO cache) and KIROCLI_DB_PATH selectors must not be snapshotted for rollback.
|
|
129
|
-
return
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
246
|
+
return resolveKiroCliNativeSessionEntries({
|
|
247
|
+
env: process.env,
|
|
248
|
+
platform: process.platform,
|
|
249
|
+
// POSIX keeps HOME-first userHome() so existing HOME-based fixtures still resolve; win32 prefers
|
|
250
|
+
// LOCALAPPDATA/USERPROFILE and only falls back to this platform-native home.
|
|
251
|
+
home: process.platform === "win32" ? homedir() : userHome(),
|
|
252
|
+
});
|
|
133
253
|
}
|
|
134
254
|
|
|
135
255
|
function sqliteEntries(): Array<{ location: KiroImportDiagnostic["location"]; path: string }> {
|
package/src/oauth/kiro.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
persistKiroCliSessionRecovery,
|
|
19
19
|
readImportedKiroCredential,
|
|
20
20
|
readKiroCliSqliteCredential,
|
|
21
|
+
resolveKiroCliExecutable,
|
|
21
22
|
restoreKiroCliSession,
|
|
22
23
|
restoreStaleKiroCliSessionRecovery,
|
|
23
24
|
requireKiroRegion,
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
type KiroCliSessionSnapshot,
|
|
26
27
|
type KiroImportDiagnostic,
|
|
27
28
|
} from "./kiro-credentials";
|
|
29
|
+
import { homedir } from "node:os";
|
|
28
30
|
import { getAccountSet, saveAccountCredential } from "./store";
|
|
29
31
|
|
|
30
32
|
const DEFAULT_REGION = "us-east-1";
|
|
@@ -72,9 +74,18 @@ const pendingKiroLoginTransactions = new WeakMap<OAuthCredentials, KiroCliSessio
|
|
|
72
74
|
/** Forced logins that started with no native CLI DB must logout on persistence failure. */
|
|
73
75
|
const pendingKiroEmptyPriorSessions = new WeakSet<OAuthCredentials>();
|
|
74
76
|
|
|
77
|
+
|
|
78
|
+
function resolveRuntimeKiroCliExecutable(): string {
|
|
79
|
+
return resolveKiroCliExecutable({
|
|
80
|
+
env: process.env,
|
|
81
|
+
platform: process.platform,
|
|
82
|
+
home: process.platform === "win32" ? homedir() : (process.env.HOME || homedir()),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
75
86
|
function logoutKiroCliBestEffort(): void {
|
|
76
87
|
try {
|
|
77
|
-
Bun.spawnSync([
|
|
88
|
+
Bun.spawnSync([resolveRuntimeKiroCliExecutable(), "logout"], {
|
|
78
89
|
stdin: "ignore",
|
|
79
90
|
stdout: "ignore",
|
|
80
91
|
stderr: "ignore",
|
|
@@ -128,7 +139,7 @@ async function defaultKiroCliRunner(args: string[], signal?: AbortSignal): Promi
|
|
|
128
139
|
throwIfKiroLoginCancelled(signal);
|
|
129
140
|
let child: ReturnType<typeof Bun.spawn>;
|
|
130
141
|
try {
|
|
131
|
-
child = Bun.spawn([
|
|
142
|
+
child = Bun.spawn([resolveRuntimeKiroCliExecutable(), ...args], {
|
|
132
143
|
stdin: "ignore",
|
|
133
144
|
stdout: "pipe",
|
|
134
145
|
stderr: "ignore",
|
|
@@ -289,7 +300,8 @@ export async function loginKiro(ctrl: OAuthController, options: KiroLoginOptions
|
|
|
289
300
|
"Kiro CLI session could not be backed up, so OCX will not sign it out. " +
|
|
290
301
|
"Repair or remove the unreadable kiro-cli credential database " +
|
|
291
302
|
"(usually `~/.local/share/kiro-cli/data.sqlite3` or " +
|
|
292
|
-
"`~/Library/Application Support/kiro-cli/data.sqlite3
|
|
303
|
+
"`~/Library/Application Support/kiro-cli/data.sqlite3`, or " +
|
|
304
|
+
"`%LOCALAPPDATA%\\Kiro-Cli\\data.sqlite3` on Windows), " +
|
|
293
305
|
"unset KIROCLI_DB_PATH / KIRO_CLI_DB_FILE if set for import-only overrides, then retry.",
|
|
294
306
|
);
|
|
295
307
|
}
|
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.");
|