@bitkyc08/opencodex 2.11.1 → 2.12.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/README.md +1 -1
- package/bin/ocx.mjs +4 -0
- package/gui/dist/assets/index-BNVYzdn0.css +1 -0
- package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +94 -5
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google.ts +24 -7
- package/src/adapters/openai-chat.ts +233 -305
- package/src/adapters/openai-responses.ts +62 -0
- package/src/adapters/tool-catalog-nudge.ts +2 -11
- package/src/claude/outbound.ts +42 -14
- package/src/cli/claude.ts +61 -9
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +8 -1
- package/src/cli/config-command.ts +4 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +27 -11
- package/src/cli/lab.ts +367 -0
- package/src/cli/provider.ts +9 -1
- package/src/clients/config-export.ts +100 -3
- package/src/codex/app-server-processes.ts +11 -8
- package/src/codex/auth-api.ts +92 -28
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/catalog/metadata.ts +7 -3
- package/src/codex/catalog/provider-fetch.ts +191 -47
- package/src/codex/catalog.ts +2 -2
- package/src/codex/history-job.ts +83 -18
- package/src/codex/history-migration-guardian.ts +9 -16
- package/src/codex/history-provider.ts +178 -1
- package/src/codex/history-transition.ts +5 -5
- package/src/codex/history-worker.ts +22 -2
- package/src/codex/native-profile-store.ts +1 -1
- package/src/codex/plan.ts +15 -0
- package/src/codex/quota.ts +6 -6
- package/src/codex/routing.ts +3 -3
- package/src/codex/subagent-model-fallback.ts +246 -22
- package/src/codex/user-identity.ts +63 -12
- package/src/config.ts +265 -32
- package/src/images/loop.ts +3 -1
- package/src/images/plan.ts +23 -13
- package/src/integrations/omp-yaml-source.ts +225 -0
- package/src/integrations/ownership.ts +5 -5
- package/src/integrations/state.ts +45 -6
- package/src/integrations/writer.ts +38 -3
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +24 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +160 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +57 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +191 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +228 -0
- package/src/lab/events/validate.ts +780 -0
- package/src/lab/index.ts +36 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +241 -0
- package/src/lab/ledger/store.ts +263 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +311 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +116 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +344 -0
- package/src/lab/observe/from-live.ts +103 -0
- package/src/lab/paths.ts +65 -0
- package/src/lab/projection/rebuild.ts +477 -0
- package/src/lab/projection/schema.ts +122 -0
- package/src/lab/projection/verdicts.ts +438 -0
- package/src/lab/projection/verification.ts +332 -0
- package/src/lab/query/catalog.ts +72 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/index.ts +34 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +81 -0
- package/src/lab/subject/route-subject.ts +73 -0
- package/src/lib/bounded-body.ts +104 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +45 -0
- package/src/lib/pinned-http.ts +36 -7
- package/src/oauth/index.ts +20 -1
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/login-cli.ts +38 -4
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/codex-capacity.ts +7 -4
- package/src/providers/command-code-efforts.ts +5 -2
- package/src/providers/derive.ts +105 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/openai-tiers.ts +59 -3
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/quota.ts +67 -21
- package/src/providers/registry.ts +118 -5
- package/src/router.ts +15 -1
- package/src/routing/analytics.ts +8 -7
- package/src/routing/evaluator.ts +51 -5
- package/src/routing/profile.ts +10 -1
- package/src/routing/trace.ts +42 -6
- package/src/server/auth-cors.ts +15 -0
- package/src/server/background-lifecycle.ts +169 -0
- package/src/server/chat-completions.ts +18 -3
- package/src/server/claude-messages.ts +1 -0
- package/src/server/images.ts +78 -11
- package/src/server/index.ts +72 -18
- package/src/server/lifecycle.ts +6 -0
- package/src/server/live.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +41 -24
- package/src/server/management/body.ts +6 -0
- package/src/server/management/config-routes.ts +51 -8
- package/src/server/management/lab-routes.ts +388 -0
- package/src/server/management/logs-usage-routes.ts +19 -1
- package/src/server/management/model-rows.ts +25 -11
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +5 -0
- package/src/server/management/shared.ts +8 -1
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management/vision-sidecar-options.ts +125 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/request-decompress.ts +106 -5
- package/src/server/responses/collaboration.ts +4 -1
- package/src/server/responses/core.ts +117 -31
- package/src/server/responses-json-events.ts +50 -12
- package/src/server/search.ts +23 -7
- package/src/server/ws-bridge.ts +3 -1
- package/src/service.ts +40 -20
- package/src/types.ts +74 -0
- package/src/update/job.ts +10 -2
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +9 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/describe.ts +2 -2
- package/src/vision/eligibility.ts +229 -0
- package/src/vision/index.ts +20 -2
- package/src/web-search/executor.ts +2 -2
- package/src/web-search/index.ts +4 -2
- package/src/web-search/loop.ts +3 -1
- package/src/web-search/parse.ts +72 -3
- package/gui/dist/assets/index-BA1dgo4q.css +0 -1
- package/gui/dist/assets/index-DDZpgzKk.js +0 -70
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { jcsStringify } from "../digest";
|
|
3
|
+
import type { LabBehaviorSource, LabBehaviorValues } from "../live/types";
|
|
4
|
+
|
|
5
|
+
const CLOSED_KEYS = new Set([
|
|
6
|
+
"wire.adapter", "wire.upstreamProtocol", "wire.responsesPath", "wire.commandCodeVersion", "wire.modelSuffixMode",
|
|
7
|
+
"auth.mode", "auth.transport",
|
|
8
|
+
"responses.stateful", "responses.upstreamStreaming", "responses.serviceTier", "responses.snapshotRepair", "responses.itemIdRepair",
|
|
9
|
+
"limits.contextWindow", "limits.maxInputTokens", "limits.maxOutputTokens",
|
|
10
|
+
"modalities.input",
|
|
11
|
+
"sampling.omitTemperature", "sampling.omitTopP", "sampling.omitPenalties",
|
|
12
|
+
"reasoning.supported", "reasoning.efforts", "reasoning.defaultEffort", "reasoning.effortMap", "reasoning.wireFormat",
|
|
13
|
+
"reasoning.summaryMode", "reasoning.replayMode", "reasoning.splitMode", "reasoning.toggleMode", "reasoning.budgetMode",
|
|
14
|
+
"tools.choiceRestrictions", "tools.parallel", "tools.hostedPreference", "tools.customFreeform", "tools.builtinNameEscaping",
|
|
15
|
+
"cache.forwarding", "cache.retention", "anthropic.eofPolicy",
|
|
16
|
+
"google.mode", "google.projectFingerprint", "google.locationFingerprint",
|
|
17
|
+
"openrouter.order", "openrouter.only", "openrouter.allowFallbacks",
|
|
18
|
+
"sidecars.vision", "sidecars.webSearch",
|
|
19
|
+
"mcp.maxTools", "mcp.maxSchemaBytes", "mcp.maxResultBytes", "mcp.nativeLocalExec",
|
|
20
|
+
"runtime.bunVersion", "runtime.platform", "runtime.arch", "runtime.streamMode", "runtime.fastMode", "runtime.effortCap",
|
|
21
|
+
"headers.nonCredentialBehaviorDigest",
|
|
22
|
+
] as const);
|
|
23
|
+
|
|
24
|
+
const SOURCES = new Set<LabBehaviorSource>([
|
|
25
|
+
"request", "model_override", "provider_config", "registry_runtime_default", "generated_model_metadata",
|
|
26
|
+
"global_config", "adapter_default", "lab_forced",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const SET_ARRAY_KEYS = new Set(["modalities.input", "reasoning.efforts", "tools.choiceRestrictions", "openrouter.only"]);
|
|
30
|
+
const ORDERED_ARRAY_KEYS = new Set(["openrouter.order"]);
|
|
31
|
+
const REQUIRED_KEYS = [
|
|
32
|
+
"wire.adapter", "wire.upstreamProtocol", "auth.mode", "auth.transport", "mcp.nativeLocalExec",
|
|
33
|
+
"runtime.bunVersion", "runtime.platform", "runtime.arch", "runtime.streamMode", "runtime.fastMode", "runtime.effortCap",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
function canonicalArray(key: string, value: unknown[]): unknown[] {
|
|
37
|
+
if (SET_ARRAY_KEYS.has(key)) {
|
|
38
|
+
const byBytes = value.map((item) => ({ item, bytes: jcsStringify(item) }));
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
for (const row of byBytes) {
|
|
41
|
+
if (seen.has(row.bytes)) throw new Error(`harness_failure: duplicate behavior array value for ${key}`);
|
|
42
|
+
seen.add(row.bytes);
|
|
43
|
+
}
|
|
44
|
+
byBytes.sort((a, b) => a.bytes < b.bytes ? -1 : a.bytes > b.bytes ? 1 : 0);
|
|
45
|
+
return byBytes.map((row) => row.item);
|
|
46
|
+
}
|
|
47
|
+
if (ORDERED_ARRAY_KEYS.has(key)) return [...value];
|
|
48
|
+
throw new Error(`harness_failure: unclassified_behavior_input array for ${key}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeBehaviorValues(values: LabBehaviorValues): LabBehaviorValues {
|
|
52
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) {
|
|
53
|
+
throw new Error("harness_failure: behavior resolver output is required");
|
|
54
|
+
}
|
|
55
|
+
for (const key of REQUIRED_KEYS) {
|
|
56
|
+
if (!(key in values)) throw new Error(`harness_failure: unclassified_behavior_input missing ${key}`);
|
|
57
|
+
}
|
|
58
|
+
const normalized: LabBehaviorValues = {};
|
|
59
|
+
for (const key of Object.keys(values).sort()) {
|
|
60
|
+
if (!CLOSED_KEYS.has(key as never)) throw new Error(`harness_failure: unclassified_behavior_input ${key}`);
|
|
61
|
+
const row = values[key];
|
|
62
|
+
if (!row || typeof row !== "object" || Array.isArray(row) || !SOURCES.has(row.source)) {
|
|
63
|
+
throw new Error(`harness_failure: invalid behavior source for ${key}`);
|
|
64
|
+
}
|
|
65
|
+
normalized[key] = {
|
|
66
|
+
source: row.source,
|
|
67
|
+
value: Array.isArray(row.value) ? canonicalArray(key, row.value) : row.value,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Hash the authoritative production resolver output; Lab performs validation/canonicalization only. */
|
|
74
|
+
export function buildBehaviorFingerprintV1(values: LabBehaviorValues): string {
|
|
75
|
+
const payload = { schemaVersion: 1, resolverVersion: 1, values: normalizeBehaviorValues(values) };
|
|
76
|
+
return createHash("sha256").update(jcsStringify(payload)).digest("hex");
|
|
77
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { labInstallationSaltPath, labRoot } from "../paths";
|
|
5
|
+
|
|
6
|
+
const SALT_BYTES = 32;
|
|
7
|
+
const UNSUPPORTED_DIRECTORY_FSYNC_CODES = new Set(["EINVAL", "ENOTSUP", "EOPNOTSUPP", "ENOSYS"]);
|
|
8
|
+
|
|
9
|
+
function readSaltFile(path: string): Uint8Array {
|
|
10
|
+
const bytes = readFileSync(path);
|
|
11
|
+
if (bytes.byteLength !== SALT_BYTES) {
|
|
12
|
+
throw new Error("harness_failure: invalid installation salt length");
|
|
13
|
+
}
|
|
14
|
+
return new Uint8Array(bytes);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function removeStagingFile(path: string): void {
|
|
18
|
+
try { unlinkSync(path); }
|
|
19
|
+
catch (error) {
|
|
20
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fsyncDirectory(path: string): void {
|
|
25
|
+
// Windows has no portable directory-fsync primitive. Some POSIX-backed
|
|
26
|
+
// filesystems (notably virtual/shared filesystems) report directory fsync as
|
|
27
|
+
// unsupported. In those cases the fsync'd staging inode plus atomic same-dir
|
|
28
|
+
// hard-link publication is the supported durability fallback. Unexpected I/O
|
|
29
|
+
// errors remain fatal.
|
|
30
|
+
if (process.platform === "win32") return;
|
|
31
|
+
const dirFd = openSync(path, "r");
|
|
32
|
+
try {
|
|
33
|
+
try { fsyncSync(dirFd); }
|
|
34
|
+
catch (error) {
|
|
35
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
36
|
+
if (code && UNSUPPORTED_DIRECTORY_FSYNC_CODES.has(code)) return;
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
} finally { closeSync(dirFd); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read or atomically publish the per-installation salt used for local fingerprinting. */
|
|
43
|
+
export function readInstallationSalt(configDir?: string): Uint8Array {
|
|
44
|
+
const path = labInstallationSaltPath(configDir);
|
|
45
|
+
const root = labRoot(configDir);
|
|
46
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
47
|
+
|
|
48
|
+
try { return readSaltFile(path); }
|
|
49
|
+
catch (error) {
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The final pathname must never become visible before all 32 bytes are durable in the
|
|
54
|
+
// staging inode. A hard-link publish is atomic and fails with EEXIST when another process won.
|
|
55
|
+
const stagingPath = `${path}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
56
|
+
if (dirname(stagingPath) !== dirname(path)) throw new Error("harness_failure: installation salt staging path escaped directory");
|
|
57
|
+
const salt = randomBytes(SALT_BYTES);
|
|
58
|
+
let fd: number | undefined;
|
|
59
|
+
try {
|
|
60
|
+
fd = openSync(stagingPath, "wx", 0o600);
|
|
61
|
+
writeFileSync(fd, salt);
|
|
62
|
+
fsyncSync(fd);
|
|
63
|
+
closeSync(fd);
|
|
64
|
+
fd = undefined;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
linkSync(stagingPath, path);
|
|
68
|
+
try { fsyncDirectory(dirname(path)); }
|
|
69
|
+
catch { throw new Error("harness_failure: installation salt directory fsync failed"); }
|
|
70
|
+
return new Uint8Array(salt);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
73
|
+
return readSaltFile(path);
|
|
74
|
+
}
|
|
75
|
+
} finally {
|
|
76
|
+
if (fd !== undefined) {
|
|
77
|
+
try { closeSync(fd); } catch { /* best-effort close before cleanup */ }
|
|
78
|
+
}
|
|
79
|
+
removeStagingFile(stagingPath);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { localFingerprint } from "../digest";
|
|
2
|
+
import type { RouteDependencyV1, RouteSubjectV1 } from "../events/types";
|
|
3
|
+
import type { LabDestinationV1, LabRouteContext } from "../live/types";
|
|
4
|
+
import { buildBehaviorFingerprintV1 } from "./behavior-fingerprint";
|
|
5
|
+
import { readInstallationSalt } from "./installation-salt";
|
|
6
|
+
|
|
7
|
+
function compareDependency(a: RouteDependencyV1, b: RouteDependencyV1): number {
|
|
8
|
+
const fields: Array<keyof RouteDependencyV1> = [
|
|
9
|
+
"role", "providerId", "providerInstanceFingerprint", "upstreamModelId", "endpointFingerprint",
|
|
10
|
+
"clientModelId", "effectiveAdapter", "upstreamProtocol", "behaviorFingerprint",
|
|
11
|
+
];
|
|
12
|
+
for (const field of fields) {
|
|
13
|
+
const av = a[field];
|
|
14
|
+
const bv = b[field];
|
|
15
|
+
if (av < bv) return -1;
|
|
16
|
+
if (av > bv) return 1;
|
|
17
|
+
}
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function canonicalDependencies(dependencies: RouteDependencyV1[] | undefined): RouteDependencyV1[] {
|
|
22
|
+
const rows = (dependencies ?? []).map((row) => Object.freeze({ ...row }));
|
|
23
|
+
rows.sort(compareDependency);
|
|
24
|
+
for (let i = 1; i < rows.length; i++) {
|
|
25
|
+
if (compareDependency(rows[i - 1]!, rows[i]!) === 0) {
|
|
26
|
+
throw new Error("harness_failure: duplicate route dependency");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return rows;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build RouteSubjectV1 only from an approved immutable destination and authoritative effective resolver inputs. */
|
|
33
|
+
export function buildRouteSubjectV1(
|
|
34
|
+
routeContext: LabRouteContext,
|
|
35
|
+
destination: LabDestinationV1,
|
|
36
|
+
configDir?: string,
|
|
37
|
+
): RouteSubjectV1 {
|
|
38
|
+
if (!routeContext.providerInstanceKey) throw new Error("harness_failure: provider instance identity is required");
|
|
39
|
+
if (!/^[0-9a-f]{64}$/.test(routeContext.opencodexCompatibilityVersion)) {
|
|
40
|
+
throw new Error("harness_failure: invalid opencodexCompatibilityVersion");
|
|
41
|
+
}
|
|
42
|
+
const adapterValue = routeContext.behaviorValues["wire.adapter"]?.value;
|
|
43
|
+
const protocolValue = routeContext.behaviorValues["wire.upstreamProtocol"]?.value;
|
|
44
|
+
if (adapterValue !== routeContext.effectiveAdapter || protocolValue !== routeContext.upstreamProtocol) {
|
|
45
|
+
throw new Error("harness_failure: behavior resolver output does not match exact route");
|
|
46
|
+
}
|
|
47
|
+
const salt = readInstallationSalt(configDir);
|
|
48
|
+
const providerInstanceFingerprint = localFingerprint("providerInstance", routeContext.providerInstanceKey, salt);
|
|
49
|
+
const dependencies = canonicalDependencies(routeContext.dependencies);
|
|
50
|
+
const subject: RouteSubjectV1 = {
|
|
51
|
+
subjectSchemaVersion: 1,
|
|
52
|
+
subjectKind: "route",
|
|
53
|
+
providerId: routeContext.providerId,
|
|
54
|
+
providerInstanceFingerprint,
|
|
55
|
+
clientModelId: routeContext.clientModelId,
|
|
56
|
+
upstreamModelId: routeContext.upstreamModelId,
|
|
57
|
+
effectiveAdapter: routeContext.effectiveAdapter,
|
|
58
|
+
inboundProtocol: routeContext.inboundProtocol,
|
|
59
|
+
upstreamProtocol: routeContext.upstreamProtocol,
|
|
60
|
+
surface: routeContext.surface,
|
|
61
|
+
opencodexCompatibilityVersion: routeContext.opencodexCompatibilityVersion,
|
|
62
|
+
behaviorFingerprint: buildBehaviorFingerprintV1(routeContext.behaviorValues),
|
|
63
|
+
endpointFingerprint: destination.fingerprint,
|
|
64
|
+
dependencies,
|
|
65
|
+
};
|
|
66
|
+
return freezeRouteSubject(subject);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Deep-freeze route dependencies as well as the subject shell. */
|
|
70
|
+
export function freezeRouteSubject(subject: RouteSubjectV1): RouteSubjectV1 {
|
|
71
|
+
const dependencies = subject.dependencies.map((row) => Object.freeze({ ...row }));
|
|
72
|
+
return Object.freeze({ ...subject, dependencies: Object.freeze(dependencies) as unknown as RouteDependencyV1[] });
|
|
73
|
+
}
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -44,6 +44,26 @@ export interface BoundedBodyResult {
|
|
|
44
44
|
displaySafe: boolean;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
export interface BoundedBytesOptions {
|
|
48
|
+
/** Abort the read with this signal. Its reason is rethrown by identity. */
|
|
49
|
+
signal?: AbortSignal;
|
|
50
|
+
/** Maximum number of raw bytes retained from the response body. */
|
|
51
|
+
maxBytes: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BoundedBytesResult {
|
|
55
|
+
/**
|
|
56
|
+
* Exact raw bytes retained from the response. Empty when the cap was exceeded.
|
|
57
|
+
*
|
|
58
|
+
* This is a view over internal storage, so `bytes.buffer.byteLength` can exceed
|
|
59
|
+
* `bytes.byteLength`. Consumers must honor the view's byteOffset and byteLength
|
|
60
|
+
* instead of reading or transferring the backing buffer directly.
|
|
61
|
+
*/
|
|
62
|
+
bytes: Uint8Array<ArrayBuffer>;
|
|
63
|
+
/** True when the body was observed to exceed the byte cap. */
|
|
64
|
+
oversized: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
const TOTAL_TIMEOUT = Symbol("bounded body total timeout");
|
|
48
68
|
const INACTIVITY_TIMEOUT = Symbol("bounded body inactivity timeout");
|
|
49
69
|
|
|
@@ -83,6 +103,90 @@ function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, r
|
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Consume the original response body as raw bytes under a strict memory ceiling.
|
|
108
|
+
*
|
|
109
|
+
* The caller owns any wall-clock deadline through `signal`, which lets one budget
|
|
110
|
+
* cover both response headers and body consumption. No decoding, cloning, or teeing
|
|
111
|
+
* occurs, so arbitrary upstream bytes remain unchanged.
|
|
112
|
+
*/
|
|
113
|
+
export async function readBoundedResponseBytes(
|
|
114
|
+
response: Response,
|
|
115
|
+
options: BoundedBytesOptions,
|
|
116
|
+
): Promise<BoundedBytesResult> {
|
|
117
|
+
const signal = options.signal;
|
|
118
|
+
if (signal?.aborted) throw signal.reason;
|
|
119
|
+
|
|
120
|
+
const body = response.body;
|
|
121
|
+
if (!body) return { bytes: new Uint8Array(0), oversized: false };
|
|
122
|
+
|
|
123
|
+
const reader = body.getReader();
|
|
124
|
+
const maxBytes = options.maxBytes;
|
|
125
|
+
let retained = new Uint8Array(Math.min(maxBytes, 64 * 1024));
|
|
126
|
+
let retainedBytes = 0;
|
|
127
|
+
let mustCancel = false;
|
|
128
|
+
let cancelReason: unknown;
|
|
129
|
+
|
|
130
|
+
let rejectForAbort: ((reason: unknown) => void) | undefined;
|
|
131
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
132
|
+
rejectForAbort = reject;
|
|
133
|
+
});
|
|
134
|
+
const onAbort = () => rejectForAbort?.(signal?.reason);
|
|
135
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
136
|
+
// Close the narrow race between the preflight check and listener install.
|
|
137
|
+
if (signal?.aborted) onAbort();
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
while (true) {
|
|
141
|
+
const read = reader.read();
|
|
142
|
+
// Observe a late read rejection when abort/cancellation wins the race.
|
|
143
|
+
void read.catch(() => undefined);
|
|
144
|
+
const outcome = await Promise.race([read, aborted]);
|
|
145
|
+
if (signal?.aborted) {
|
|
146
|
+
mustCancel = true;
|
|
147
|
+
cancelReason = signal.reason;
|
|
148
|
+
throw signal.reason;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { value, done } = outcome;
|
|
152
|
+
if (done) {
|
|
153
|
+
return { bytes: retained.subarray(0, retainedBytes), oversized: false };
|
|
154
|
+
}
|
|
155
|
+
if (!value || value.byteLength === 0) continue;
|
|
156
|
+
|
|
157
|
+
if (value.byteLength > maxBytes - retainedBytes) {
|
|
158
|
+
mustCancel = true;
|
|
159
|
+
cancelReason = new DOMException("Response body size limit reached", "QuotaExceededError");
|
|
160
|
+
retained = new Uint8Array(0);
|
|
161
|
+
retainedBytes = 0;
|
|
162
|
+
return { bytes: retained, oversized: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (retainedBytes + value.byteLength > retained.length) {
|
|
166
|
+
const grown = new Uint8Array(
|
|
167
|
+
Math.min(maxBytes, Math.max(retained.length * 2, retainedBytes + value.byteLength)),
|
|
168
|
+
);
|
|
169
|
+
grown.set(retained.subarray(0, retainedBytes));
|
|
170
|
+
retained = grown;
|
|
171
|
+
}
|
|
172
|
+
retained.set(value, retainedBytes);
|
|
173
|
+
retainedBytes += value.byteLength;
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
mustCancel = true;
|
|
177
|
+
cancelReason = error;
|
|
178
|
+
throw error;
|
|
179
|
+
} finally {
|
|
180
|
+
signal?.removeEventListener("abort", onAbort);
|
|
181
|
+
if (mustCancel) cancelWithoutWaiting(reader, cancelReason);
|
|
182
|
+
try {
|
|
183
|
+
reader.releaseLock();
|
|
184
|
+
} catch {
|
|
185
|
+
// A pending read can keep the lock briefly while cancel settles.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
86
190
|
function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean): string {
|
|
87
191
|
const decoder = new TextDecoder("utf-8", { fatal });
|
|
88
192
|
let text = "";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TrustedLabRouteExecutor } from "../lab/live/types";
|
|
2
|
+
import { isHostIssuedLabRouteExecutor } from "./lab-live-host";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read-only trust check used by the Lab executor.
|
|
6
|
+
*
|
|
7
|
+
* Capability issuance intentionally does not live in this authority module: callers cannot
|
|
8
|
+
* self-attest sandbox boundary names here. Only the dedicated trusted host integration may
|
|
9
|
+
* construct and register an evidence-eligible route executor.
|
|
10
|
+
*/
|
|
11
|
+
export function isTrustedLabRouteExecutor(value: unknown): value is TrustedLabRouteExecutor {
|
|
12
|
+
return isHostIssuedLabRouteExecutor(value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LabRouteExecutor, TrustedLabRouteExecutor } from "../lab/live/types";
|
|
2
|
+
import { REQUIRED_LAB_SANDBOX_BOUNDARIES } from "../lab/live/types";
|
|
3
|
+
|
|
4
|
+
const HOST_ISSUED_EXECUTORS = new WeakSet<object>();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Trusted host integration boundary for CL-03 exact-route execution.
|
|
8
|
+
*
|
|
9
|
+
* This module is intentionally not re-exported from the Lab public surface. The host owns
|
|
10
|
+
* enforcement of every mandatory sandbox boundary and therefore supplies no caller-controlled
|
|
11
|
+
* boundary list. Adapters/providers receive only the resulting opaque capability.
|
|
12
|
+
*
|
|
13
|
+
* @internal host integration only
|
|
14
|
+
*/
|
|
15
|
+
export function createHostIssuedLabRouteExecutor(execute: LabRouteExecutor): TrustedLabRouteExecutor {
|
|
16
|
+
const capability: TrustedLabRouteExecutor = Object.freeze({
|
|
17
|
+
execute,
|
|
18
|
+
enforcedBoundaries: Object.freeze([...REQUIRED_LAB_SANDBOX_BOUNDARIES]),
|
|
19
|
+
});
|
|
20
|
+
HOST_ISSUED_EXECUTORS.add(capability);
|
|
21
|
+
return capability;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Internal recognition check consumed by the read-only authority facade. */
|
|
25
|
+
export function isHostIssuedLabRouteExecutor(value: unknown): value is TrustedLabRouteExecutor {
|
|
26
|
+
if (typeof value !== "object" || value === null || !HOST_ISSUED_EXECUTORS.has(value as object)) return false;
|
|
27
|
+
const capability = value as TrustedLabRouteExecutor;
|
|
28
|
+
return capability.enforcedBoundaries.length === REQUIRED_LAB_SANDBOX_BOUNDARIES.length
|
|
29
|
+
&& REQUIRED_LAB_SANDBOX_BOUNDARIES.every((boundary, index) => capability.enforcedBoundaries[index] === boundary);
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PinnedHttpError, pinnedHttpGet, pinnedHttpPost } from "./pinned-http";
|
|
2
|
+
import { TransportError } from "../lab/live/transport";
|
|
3
|
+
import type { LabCredentialLeaseV1, LabPinnedSender } from "../lab/live/types";
|
|
4
|
+
|
|
5
|
+
/** Only response metadata required by current live assertions crosses into Lab. */
|
|
6
|
+
const LAB_RESPONSE_HEADER_ALLOWLIST = ["content-type"] as const;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Trusted credential/transport owner. Secret headers exist only in this non-Lab module and are
|
|
10
|
+
* consumed directly by the pinned HTTP primitive; they are never returned to Lab code.
|
|
11
|
+
*/
|
|
12
|
+
export function createLabAuthorizedPinnedSender(
|
|
13
|
+
authorize: (lease: LabCredentialLeaseV1) => Promise<HeadersInit> | HeadersInit,
|
|
14
|
+
): LabPinnedSender {
|
|
15
|
+
return async (lease, destination, pinned, request, signal, limits) => {
|
|
16
|
+
const headers = await authorize(lease);
|
|
17
|
+
const url = `${destination.scheme}://${destination.host}:${destination.port}${destination.basePath}${request.path}`;
|
|
18
|
+
const options = {
|
|
19
|
+
headers,
|
|
20
|
+
maxBytes: limits.maxOutputBytes,
|
|
21
|
+
connectTimeoutMs: limits.connectTimeoutMs,
|
|
22
|
+
idleTimeoutMs: Math.min(limits.firstByteTimeoutMs, limits.inactivityTimeoutMs),
|
|
23
|
+
rejectUnauthorized: true,
|
|
24
|
+
context: "Lab provider response",
|
|
25
|
+
};
|
|
26
|
+
let response: Response;
|
|
27
|
+
try {
|
|
28
|
+
response = request.method === "POST"
|
|
29
|
+
? await pinnedHttpPost(url, pinned, request.body ?? "", signal, options)
|
|
30
|
+
: await pinnedHttpGet(url, pinned, signal, options);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error instanceof PinnedHttpError && error.code === "connect_timeout") {
|
|
33
|
+
throw new TransportError("connect_timeout", "pinned provider connection timed out");
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const body = await response.text();
|
|
38
|
+
const responseHeaders: Record<string, string> = {};
|
|
39
|
+
for (const headerName of LAB_RESPONSE_HEADER_ALLOWLIST) {
|
|
40
|
+
const value = response.headers.get(headerName);
|
|
41
|
+
if (value !== null) responseHeaders[headerName] = value;
|
|
42
|
+
}
|
|
43
|
+
return { status: response.status, headers: responseHeaders, body };
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/lib/pinned-http.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
import http, { type IncomingMessage, type RequestOptions } from "node:http";
|
|
1
|
+
import http, { type ClientRequest, type IncomingMessage, type RequestOptions } from "node:http";
|
|
2
2
|
import https from "node:https";
|
|
3
3
|
|
|
4
4
|
export type PinnedAddress = { address: string; family: number };
|
|
5
5
|
|
|
6
|
+
export type PinnedHttpErrorCode = "connect_timeout";
|
|
7
|
+
|
|
8
|
+
export class PinnedHttpError extends Error {
|
|
9
|
+
override readonly name = "PinnedHttpError";
|
|
10
|
+
constructor(readonly code: PinnedHttpErrorCode, message: string) { super(message); }
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export interface PinnedHttpRequestOptions {
|
|
7
14
|
headers?: HeadersInit;
|
|
8
15
|
maxBytes?: number;
|
|
16
|
+
/** Optional deadline for establishing the TCP connection and, for HTTPS, completing TLS. */
|
|
17
|
+
connectTimeoutMs?: number;
|
|
9
18
|
idleTimeoutMs?: number;
|
|
10
19
|
rejectUnauthorized?: boolean;
|
|
11
20
|
context?: string;
|
|
@@ -27,6 +36,7 @@ function pinnedHttpRequest(
|
|
|
27
36
|
throw new Error(`${options?.context ?? "request"} must use HTTP or HTTPS, got ${parsed.protocol}`);
|
|
28
37
|
}
|
|
29
38
|
const context = options?.context ?? "request";
|
|
39
|
+
const connectTimeoutMs = options?.connectTimeoutMs;
|
|
30
40
|
const idleTimeoutMs = options?.idleTimeoutMs ?? 60_000;
|
|
31
41
|
const maxBytes = options?.maxBytes;
|
|
32
42
|
const headers = new Headers(options?.headers);
|
|
@@ -44,8 +54,15 @@ function pinnedHttpRequest(
|
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
let settled = false;
|
|
57
|
+
let req: ClientRequest | undefined;
|
|
58
|
+
let connectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
const clearConnectTimer = () => {
|
|
60
|
+
if (connectTimer !== undefined) clearTimeout(connectTimer);
|
|
61
|
+
connectTimer = undefined;
|
|
62
|
+
};
|
|
47
63
|
const fail = (error: unknown) => {
|
|
48
|
-
|
|
64
|
+
clearConnectTimer();
|
|
65
|
+
try { req?.destroy(); } catch { /* ignore */ }
|
|
49
66
|
if (settled) return;
|
|
50
67
|
settled = true;
|
|
51
68
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
@@ -83,6 +100,7 @@ function pinnedHttpRequest(
|
|
|
83
100
|
};
|
|
84
101
|
|
|
85
102
|
const onResponse = (response: IncomingMessage) => {
|
|
103
|
+
clearConnectTimer();
|
|
86
104
|
const status = response.statusCode ?? 0;
|
|
87
105
|
const responseHeaders = new Headers();
|
|
88
106
|
for (const [key, value] of Object.entries(response.headers)) {
|
|
@@ -96,7 +114,7 @@ function pinnedHttpRequest(
|
|
|
96
114
|
|
|
97
115
|
if (status < 200 || status >= 300) {
|
|
98
116
|
try { response.destroy(); } catch { /* ignore */ }
|
|
99
|
-
try { req
|
|
117
|
+
try { req?.destroy(); } catch { /* ignore */ }
|
|
100
118
|
if (settled) return;
|
|
101
119
|
settled = true;
|
|
102
120
|
resolve(new Response(null, { status, headers: responseHeaders }));
|
|
@@ -131,7 +149,7 @@ function pinnedHttpRequest(
|
|
|
131
149
|
});
|
|
132
150
|
},
|
|
133
151
|
cancel() {
|
|
134
|
-
req
|
|
152
|
+
req?.destroy();
|
|
135
153
|
},
|
|
136
154
|
});
|
|
137
155
|
|
|
@@ -141,15 +159,26 @@ function pinnedHttpRequest(
|
|
|
141
159
|
};
|
|
142
160
|
|
|
143
161
|
const requestFn = parsed.protocol === "https:" ? https.request : http.request;
|
|
144
|
-
|
|
162
|
+
req = requestFn(requestOptions, onResponse);
|
|
145
163
|
const onAbort = () => fail(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
146
164
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
165
|
+
req.on("socket", (socket) => {
|
|
166
|
+
if (!socket.connecting || connectTimeoutMs === undefined) return;
|
|
167
|
+
const connectedEvent = parsed.protocol === "https:" ? "secureConnect" : "connect";
|
|
168
|
+
connectTimer = setTimeout(() => fail(new PinnedHttpError("connect_timeout", `${context} connect timed out`)), connectTimeoutMs);
|
|
169
|
+
socket.once(connectedEvent, clearConnectTimer);
|
|
170
|
+
socket.once("error", clearConnectTimer);
|
|
171
|
+
socket.once("close", clearConnectTimer);
|
|
172
|
+
});
|
|
147
173
|
req.setTimeout(idleTimeoutMs, () => fail(new Error(`${context} timed out`)));
|
|
148
174
|
req.on("error", error => {
|
|
149
175
|
signal?.removeEventListener("abort", onAbort);
|
|
150
176
|
fail(error);
|
|
151
177
|
});
|
|
152
|
-
req.on("close", () =>
|
|
178
|
+
req.on("close", () => {
|
|
179
|
+
clearConnectTimer();
|
|
180
|
+
signal?.removeEventListener("abort", onAbort);
|
|
181
|
+
});
|
|
153
182
|
req.end(body);
|
|
154
183
|
});
|
|
155
184
|
}
|
|
@@ -179,4 +208,4 @@ export function pinnedHttpPost(
|
|
|
179
208
|
options?: PinnedHttpRequestOptions,
|
|
180
209
|
): Promise<Response> {
|
|
181
210
|
return pinnedHttpRequest(url, pinned, "POST", body, signal, options);
|
|
182
|
-
}
|
|
211
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -740,6 +740,11 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
|
|
|
740
740
|
"autoToolChoiceOnlyModels",
|
|
741
741
|
"preserveReasoningContentModels",
|
|
742
742
|
];
|
|
743
|
+
// `requiresReasoningPlaceholderModels` is deliberately NOT reconciled here: no
|
|
744
|
+
// OAuth preset seeds it, so the delete-when-preset-undefined branch would wipe
|
|
745
|
+
// an explicit user opt-out (`[]`) on every startup. Registry seeds still reach
|
|
746
|
+
// existing rows through enrichProviderFromRegistry, which is fill-only and
|
|
747
|
+
// preserves explicit saved values.
|
|
743
748
|
|
|
744
749
|
const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity";
|
|
745
750
|
const GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION = 2 as const;
|
|
@@ -883,6 +888,12 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
883
888
|
if (existing?.commandCodeVersion !== undefined) {
|
|
884
889
|
next.commandCodeVersion = existing.commandCodeVersion;
|
|
885
890
|
}
|
|
891
|
+
// User-configured price overlays are operator data, not preset state; a
|
|
892
|
+
// re-login, add-account, or reauth must not silently drop them from the
|
|
893
|
+
// Logs/Usage estimates.
|
|
894
|
+
if (existing?.modelCosts !== undefined) {
|
|
895
|
+
next.modelCosts = existing.modelCosts;
|
|
896
|
+
}
|
|
886
897
|
if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
|
|
887
898
|
// Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
|
|
888
899
|
let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
|
|
@@ -1174,8 +1185,16 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
|
|
|
1174
1185
|
...(a.needsReauth ? { needsReauth: true } : {}),
|
|
1175
1186
|
expiresAt: a.credential.expires,
|
|
1176
1187
|
}));
|
|
1188
|
+
|
|
1189
|
+
// A stored credential counts as "logged in" when it exists and is not marked for
|
|
1190
|
+
// re-authentication. An expired access token with a valid refresh token is still
|
|
1191
|
+
// logged in: request resolution refreshes expired/near-expiry credentials lazily.
|
|
1192
|
+
// Invalid/unknown local-import expiries are handled at parse/adoption time
|
|
1193
|
+
// (local-token-detect.ts), never by over-reporting login state here.
|
|
1194
|
+
const activeNeedsReauth = set?.accounts
|
|
1195
|
+
.find(a => a.id === set.activeAccountId)?.needsReauth === true;
|
|
1177
1196
|
return {
|
|
1178
|
-
loggedIn: !!cred,
|
|
1197
|
+
loggedIn: !!cred && !activeNeedsReauth,
|
|
1179
1198
|
email: maskEmail(cred?.email) ?? undefined,
|
|
1180
1199
|
source: cred?.source,
|
|
1181
1200
|
error: st?.error,
|
|
@@ -24,7 +24,10 @@ export function detectGrokCliToken(): OAuthCredentials | null {
|
|
|
24
24
|
|
|
25
25
|
const accessToken = entry.key as string;
|
|
26
26
|
const refreshToken = entry.refresh_token as string;
|
|
27
|
-
const
|
|
27
|
+
const parsedExpiresAt = entry.expires_at ? new Date(entry.expires_at as string).getTime() : 0;
|
|
28
|
+
// Guard against unparseable/NaN expiries: a non-finite value must never be treated as
|
|
29
|
+
// "valid forever". Unknown → 0, which forces the refresh-validation path downstream.
|
|
30
|
+
const expiresAt = Number.isFinite(parsedExpiresAt) ? parsedExpiresAt : 0;
|
|
28
31
|
|
|
29
32
|
return {
|
|
30
33
|
refresh: refreshToken,
|
|
@@ -55,6 +58,9 @@ export function shouldAdoptGrokGeneration(
|
|
|
55
58
|
now = Date.now(),
|
|
56
59
|
refreshSkewMs = 60_000,
|
|
57
60
|
): boolean {
|
|
61
|
+
// A non-finite disk expiry means we cannot reason about the generation: the credential is
|
|
62
|
+
// either garbage or unknown. Treat it as requiring refresh validation, never as an upgrade.
|
|
63
|
+
if (!Number.isFinite(disk.expires)) return false;
|
|
58
64
|
if (disk.expires <= now + refreshSkewMs) return false;
|
|
59
65
|
const bothExpiriesExist = stored.expires > 0 && disk.expires > 0;
|
|
60
66
|
if (bothExpiriesExist) return disk.expires >= stored.expires;
|
|
@@ -108,7 +114,10 @@ export function parseClaudeOauthPayload(raw: string): OAuthCredentials | null {
|
|
|
108
114
|
const data = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; refreshToken?: string; expiresAt?: number } };
|
|
109
115
|
const o = data.claudeAiOauth;
|
|
110
116
|
if (!o?.accessToken || !o?.refreshToken) return null;
|
|
111
|
-
|
|
117
|
+
// Number.isFinite guard: a string/NaN expiresAt must not flow into downstream time
|
|
118
|
+
// comparisons as a "valid forever" value. Unknown → 0 (refresh-validation path).
|
|
119
|
+
const expires = typeof o.expiresAt === "number" && Number.isFinite(o.expiresAt) ? o.expiresAt : 0;
|
|
120
|
+
return { access: o.accessToken, refresh: o.refreshToken, expires, source: "local-cli" };
|
|
112
121
|
} catch {
|
|
113
122
|
return null;
|
|
114
123
|
}
|