@bitkyc08/opencodex 2.48.0 → 2.50.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/AGENTS_INSTALL.md +9 -1
- package/README.md +11 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +29 -2
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +113 -26
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +96 -7
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +144 -11
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +128 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +71 -15
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +59 -0
- package/src/generated/compatibility-version.json +178 -114
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +140 -1
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +30 -13
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +17 -7
- package/src/server/images.ts +3 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +5 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +33 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +15 -1
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +16 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +285 -57
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +18 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +128 -9
- package/src/storage/cleanup.ts +77 -45
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +43 -1
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
|
@@ -74,13 +74,20 @@ export function providerFetch(
|
|
|
74
74
|
const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
|
|
75
75
|
base.preconnect?.(...args);
|
|
76
76
|
};
|
|
77
|
+
// Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends.
|
|
78
|
+
// Return the original 3xx so the response owner retains its retry/health/relay contract.
|
|
79
|
+
const dispatch = Object.assign(
|
|
80
|
+
(input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
|
|
81
|
+
base(input, { ...init, redirect: "manual" }),
|
|
82
|
+
{ preconnect },
|
|
83
|
+
) as typeof globalThis.fetch;
|
|
77
84
|
const httpFetch = Object.assign(
|
|
78
85
|
async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
79
86
|
options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
|
|
80
87
|
const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 };
|
|
81
88
|
return options.dispatchOverride
|
|
82
|
-
? options.dispatchOverride(input, dispatchInit,
|
|
83
|
-
:
|
|
89
|
+
? options.dispatchOverride(input, dispatchInit, dispatch)
|
|
90
|
+
: dispatch(input, dispatchInit);
|
|
84
91
|
},
|
|
85
92
|
{ preconnect },
|
|
86
93
|
) as typeof globalThis.fetch;
|
|
@@ -163,6 +170,10 @@ export function storedPoolReplayDispatchNotifier(
|
|
|
163
170
|
}) as ProviderFetch;
|
|
164
171
|
}
|
|
165
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Fetch through the header deadline with redirects always manual.
|
|
175
|
+
* @param _manualRedirect Ignored; retained for call compatibility. Even false uses manual.
|
|
176
|
+
*/
|
|
166
177
|
export async function fetchWithHeaderTimeout(
|
|
167
178
|
url: string,
|
|
168
179
|
init: Omit<RequestInit, "signal">,
|
|
@@ -170,7 +181,8 @@ export async function fetchWithHeaderTimeout(
|
|
|
170
181
|
timeoutMs: number,
|
|
171
182
|
preferIdentityEncoding = false,
|
|
172
183
|
executor: typeof globalThis.fetch = globalThis.fetch,
|
|
173
|
-
|
|
184
|
+
// Retained for existing callers; credential-bearing transport no longer opts out.
|
|
185
|
+
_manualRedirect = false,
|
|
174
186
|
): Promise<Response> {
|
|
175
187
|
const pacing = executor as ProviderFetch;
|
|
176
188
|
await pacing.waitForPacing?.(abortSignal);
|
|
@@ -189,10 +201,9 @@ export async function fetchWithHeaderTimeout(
|
|
|
189
201
|
return await fetchExecutor(url, {
|
|
190
202
|
...init,
|
|
191
203
|
headers,
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
...(manualRedirect ? { redirect: "manual" as const } : {}),
|
|
204
|
+
// Never replay provider credentials or request bodies to a redirect destination.
|
|
205
|
+
// Preserve the 3xx for the owner's existing response/health policy (#914, #1471).
|
|
206
|
+
redirect: "manual",
|
|
196
207
|
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
|
197
208
|
timeout: 0,
|
|
198
209
|
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { comboFailureDecision } from "../../combos/failover";
|
|
2
2
|
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
3
|
-
import { readJsonRequestBody } from "../request-decompress";
|
|
3
|
+
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress";
|
|
4
4
|
import { finishRequestAttempt, type RequestLogContext } from "../request-log";
|
|
5
5
|
import type { OcxConfig } from "../../types";
|
|
6
6
|
import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
|
|
7
7
|
import { handleResponses as handleResponsesCore } from "./core";
|
|
8
8
|
import { requestPacingOverloadResponse } from "./pacing-overload";
|
|
9
|
+
import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar";
|
|
10
|
+
import { captureCallerDirectAuth } from "../../providers/caller-authorization";
|
|
9
11
|
|
|
10
12
|
type CoreHandler = typeof handleResponsesCore;
|
|
11
13
|
type CoreOptions = Parameters<CoreHandler>[3];
|
|
@@ -47,6 +49,10 @@ function requestWithCandidate(
|
|
|
47
49
|
candidate: Pick<RouteCandidateTrace, "provider" | "model">,
|
|
48
50
|
): Request {
|
|
49
51
|
const headers = new Headers(req.headers);
|
|
52
|
+
// The next candidate owns a different physical credential domain. Typed
|
|
53
|
+
// admission and any claimed Claude snapshot stay in caller-owned CoreOptions.
|
|
54
|
+
headers.delete("authorization");
|
|
55
|
+
headers.delete("chatgpt-account-id");
|
|
50
56
|
headers.delete("content-encoding");
|
|
51
57
|
headers.delete("content-length");
|
|
52
58
|
headers.set("content-type", "application/json");
|
|
@@ -119,6 +125,12 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
119
125
|
let storedPool401ReplayDispatched = false;
|
|
120
126
|
const coreOptions: CoreOptions = {
|
|
121
127
|
...options,
|
|
128
|
+
openAiSidecarAuth: options.openAiSidecarAuth === undefined
|
|
129
|
+
? captureExplicitOpenAiCallerAuth(req.headers, config) : options.openAiSidecarAuth,
|
|
130
|
+
nativeCallerAuth: options.nativeCallerAuth === undefined
|
|
131
|
+
? captureExplicitOpenAiCallerAuth(req.headers, config) : options.nativeCallerAuth,
|
|
132
|
+
callerDirectAuth: options.callerDirectAuth === undefined
|
|
133
|
+
? captureCallerDirectAuth(req.headers, config) : options.callerDirectAuth,
|
|
122
134
|
...(options.onRequestBodyRead ? {
|
|
123
135
|
onRequestBodyRead: () => {
|
|
124
136
|
if (requestBodyReadNotified) return;
|
|
@@ -133,7 +145,11 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
133
145
|
};
|
|
134
146
|
let rawBody: Record<string, unknown> | null = null;
|
|
135
147
|
try {
|
|
136
|
-
const parsed = await readJsonRequestBody(
|
|
148
|
+
const parsed = await readJsonRequestBody(
|
|
149
|
+
req.clone(),
|
|
150
|
+
undefined,
|
|
151
|
+
resolveInboundBodyLimitBytes(config.maxInboundBodyBytes),
|
|
152
|
+
);
|
|
137
153
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) rawBody = parsed as Record<string, unknown>;
|
|
138
154
|
} catch {
|
|
139
155
|
// Core owns the client-facing parse/decompression error.
|
package/src/server/search.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
type ExactOpenAiSidecarAccount,
|
|
34
34
|
} from "../providers/openai-sidecar";
|
|
35
35
|
import { routeModel } from "../router";
|
|
36
|
-
import { readJsonRequestBody } from "./request-decompress";
|
|
36
|
+
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
|
|
37
37
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
38
38
|
import type { RequestLogContext } from "./request-log";
|
|
39
39
|
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
@@ -64,7 +64,7 @@ export async function handleSearch(
|
|
|
64
64
|
}
|
|
65
65
|
let body: unknown;
|
|
66
66
|
try {
|
|
67
|
-
body = await readJsonRequestBody(req);
|
|
67
|
+
body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
|
|
68
68
|
} catch (err) {
|
|
69
69
|
return decodeRequestErrorResponse(err, "search");
|
|
70
70
|
}
|
package/src/service.ts
CHANGED
|
@@ -16,6 +16,13 @@ import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
|
|
|
16
16
|
import { stripGrokConfig } from "./grok/inject";
|
|
17
17
|
import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
|
|
18
18
|
import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in
|
|
22
|
+
* for it: `ocx claude` and `ocx opencode` set that on the proxies they spawn to borrow its
|
|
23
|
+
* routing-preservation meaning, so a proxy carrying it is not necessarily the managed job.
|
|
24
|
+
*/
|
|
25
|
+
export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
|
|
19
26
|
import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime";
|
|
20
27
|
import { isProcessAlive, stopProxy } from "./lib/process-control";
|
|
21
28
|
import { serviceApiTokenFilePath } from "./lib/service-secrets";
|
|
@@ -508,6 +515,11 @@ export function buildPlist(
|
|
|
508
515
|
const opencodexHome = process.env.OPENCODEX_HOME?.trim();
|
|
509
516
|
const envLines = [
|
|
510
517
|
` <key>OCX_SERVICE</key><string>1</string>`,
|
|
518
|
+
// OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode`
|
|
519
|
+
// also set it on the proxies they spawn, to borrow its routing-preservation meaning
|
|
520
|
+
// (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so
|
|
521
|
+
// the dashboard-stop refusal below can tell a real launchd job from an ordinary child.
|
|
522
|
+
` <key>${SERVICE_MANAGED_ENV}</key><string>1</string>`,
|
|
511
523
|
...(launcher ? [] : [
|
|
512
524
|
` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
|
|
513
525
|
` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
|
|
@@ -853,9 +865,51 @@ export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name:
|
|
|
853
865
|
}
|
|
854
866
|
|
|
855
867
|
function sh(cmd: string): string {
|
|
868
|
+
assertLiveServiceManagerAllowed(cmd);
|
|
856
869
|
return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
857
870
|
}
|
|
858
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Service-manager invocations that only observe. Everything else changes a job that
|
|
874
|
+
* launchd or the systemd user manager is running right now.
|
|
875
|
+
*/
|
|
876
|
+
const READ_ONLY_SERVICE_MANAGER = new RegExp(
|
|
877
|
+
"^(?:launchctl\\s+(?:list|print|print-disabled|blame|managerpid|manageruid)\\b"
|
|
878
|
+
+ "|systemctl\\s+(?:--user\\s+)?(?:show|show-environment|status|is-active|is-enabled|is-failed|cat|list-units|list-unit-files|--version)\\b)",
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
const SERVICE_MANAGER_COMMAND = /^(?:launchctl|systemctl)\b/;
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Refuse to mutate a live service manager from an armed test process.
|
|
885
|
+
*
|
|
886
|
+
* The test preload isolates HOME, OPENCODEX_HOME and CODEX_HOME, and that is enough for
|
|
887
|
+
* anything addressed by path. It is not enough here. `systemctl --user stop
|
|
888
|
+
* opencodex-proxy.service` is addressed by job NAME and talks to the user manager that is
|
|
889
|
+
* already running, so it stops the proxy the developer is actually using no matter what
|
|
890
|
+
* HOME says. `launchctl bootout gui/<uid>/com.opencodex.proxy` has the same shape.
|
|
891
|
+
*
|
|
892
|
+
* Windows already had this guard: `querySchtasks` refuses every non-query call while the
|
|
893
|
+
* test-home guard is armed, after a partially-faked test replaced a real scheduled task
|
|
894
|
+
* with a launcher inside a temporary test home. macOS and Linux were left without the
|
|
895
|
+
* equivalent, which means the person most likely to run this suite - someone running
|
|
896
|
+
* opencodex on the machine they are developing it on - is the person it can disrupt.
|
|
897
|
+
*
|
|
898
|
+
* Read-only verbs stay allowed: probing what the manager reports is the whole point of
|
|
899
|
+
* the diagnostics, and observation cannot take a service down.
|
|
900
|
+
*/
|
|
901
|
+
export function assertLiveServiceManagerAllowed(command: string): void {
|
|
902
|
+
if (!isTestHomeGuardArmed()) return;
|
|
903
|
+
const trimmed = command.trim();
|
|
904
|
+
if (!SERVICE_MANAGER_COMMAND.test(trimmed)) return;
|
|
905
|
+
if (READ_ONLY_SERVICE_MANAGER.test(trimmed)) return;
|
|
906
|
+
throw new Error(
|
|
907
|
+
`refusing to run \`${trimmed}\` from an armed test process: launchd and the systemd user `
|
|
908
|
+
+ "manager address a job by name, not by HOME, so this reaches the service the developer is "
|
|
909
|
+
+ "actually running. Inject the service operation instead of calling the live manager.",
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
859
913
|
/**
|
|
860
914
|
* Run `launchctl` and report BOTH streams regardless of exit status.
|
|
861
915
|
*
|
|
@@ -875,6 +929,9 @@ export function runLaunchctl(
|
|
|
875
929
|
deps: { run?: typeof spawnSync } = {},
|
|
876
930
|
): { ok: boolean; stdout: string; stderr: string; status: number | null } {
|
|
877
931
|
const run = deps.run ?? spawnSync;
|
|
932
|
+
// Only the real runner is guarded. Tests that inject a spawnSync stand-in are
|
|
933
|
+
// exercising the parsing, not reaching launchd, and must keep working.
|
|
934
|
+
if (run === spawnSync) assertLiveServiceManagerAllowed(`launchctl ${args.join(" ")}`);
|
|
878
935
|
const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
|
|
879
936
|
// `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
|
|
880
937
|
// null for a signalled child; neither may be reported as success.
|
|
@@ -2281,7 +2338,23 @@ export function readWindowsSchedulerXmlState(
|
|
|
2281
2338
|
}
|
|
2282
2339
|
|
|
2283
2340
|
// ── macOS (launchd) ──
|
|
2284
|
-
|
|
2341
|
+
/**
|
|
2342
|
+
* Deps follow {@link startLaunchd}: `launchctl` replaces the LAYER, returning a
|
|
2343
|
+
* {@link runLaunchctl} result, not a spawnSync result. It is optional so this stays
|
|
2344
|
+
* assignable to `ServiceOps.install` and `RepairServiceDeps.repairLaunchd`
|
|
2345
|
+
* (`() => void`), and so `platformOps` wires the same function the tests exercise.
|
|
2346
|
+
*
|
|
2347
|
+
* The seam is what makes the eviction below testable at all. The live-service-manager
|
|
2348
|
+
* guard refuses every mutating verb from an armed test process and `bootout` is not on
|
|
2349
|
+
* its read-only list, so a test reaching the real runner would fail closed on the guard
|
|
2350
|
+
* instead of exercising the sequence.
|
|
2351
|
+
*
|
|
2352
|
+
* No `matches` dep: unlike `startLaunchd`, this function never consults
|
|
2353
|
+
* {@link launchdJobMatchesPlist}. It has just rewritten the plist, so a live job is stale
|
|
2354
|
+
* by construction and there is nothing to compare against.
|
|
2355
|
+
*/
|
|
2356
|
+
export function installLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
|
|
2357
|
+
const run = deps.launchctl ?? runLaunchctl;
|
|
2285
2358
|
const dir = join(homedir(), "Library", "LaunchAgents");
|
|
2286
2359
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2287
2360
|
recordOwnedConfigPath(getConfigDir(), serviceStatePath());
|
|
@@ -2295,17 +2368,41 @@ function installLaunchd(): void {
|
|
|
2295
2368
|
// so the staleness diagnostic judges exactly what launchd runs.
|
|
2296
2369
|
const launcher = stableLauncherEntry();
|
|
2297
2370
|
writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8");
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
|
|
2301
|
-
|
|
2371
|
+
// `unload` is the legacy verb and it does not evict a job bootstrapped into the GUI
|
|
2372
|
+
// domain — which is precisely the state that could not repair itself. Modern launchd
|
|
2373
|
+
// answers `load -w` for an already-bootstrapped job with "Load failed: 5:
|
|
2374
|
+
// Input/output error" AND exits 0, so `ocx update` replaced the binary, ran repair,
|
|
2375
|
+
// and left launchd running the PREVIOUS job while the fresh plist sat unused (#4141).
|
|
2376
|
+
//
|
|
2377
|
+
// This EVICTS the running job. That is the repair being asked for, and it is why it
|
|
2378
|
+
// lives here and nowhere else: `installLaunchd` has already rewritten the plist, so
|
|
2379
|
+
// whatever is loaded is stale by construction. `ocx service start` must never do
|
|
2380
|
+
// this, and `startLaunchd` accordingly still refuses to.
|
|
2381
|
+
//
|
|
2382
|
+
// Absence is fine: booting out a job that is not there is a no-op, and a real failure
|
|
2383
|
+
// is reported by the load verification below with a better message than a raw
|
|
2384
|
+
// eviction error would carry.
|
|
2385
|
+
const bootoutTarget = `${launchdGuiDomain()}/${LABEL}`;
|
|
2386
|
+
run(["bootout", bootoutTarget]);
|
|
2387
|
+
let loaded = run(["load", "-w", p]);
|
|
2388
|
+
if (launchctlLoadFailed(loaded.stderr)) {
|
|
2389
|
+
// Still bootstrapped after an eviction: the job re-registered between the two calls,
|
|
2390
|
+
// or the first `bootout` raced a job that had not finished exiting. Evict and load
|
|
2391
|
+
// once more — ONCE. A bounded retry recovers the race; a loop would turn a genuinely
|
|
2392
|
+
// wedged domain into a hang instead of the diagnosable throw below.
|
|
2393
|
+
run(["bootout", bootoutTarget]);
|
|
2394
|
+
loaded = run(["load", "-w", p]);
|
|
2395
|
+
}
|
|
2302
2396
|
if (!loaded.ok || launchctlLoadFailed(loaded.stderr)) {
|
|
2303
2397
|
// Do NOT write install state for a load that did not take: state describing an
|
|
2304
2398
|
// unused plist is what made this failure invisible.
|
|
2305
2399
|
throw new Error(
|
|
2306
2400
|
`launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
|
|
2307
|
-
|
|
2308
|
-
|
|
2401
|
+
// The hint used to tell the operator to run `bootout` by hand. It now runs twice
|
|
2402
|
+
// above, so naming it as an untried remedy would send someone to repeat what just
|
|
2403
|
+
// failed. Report what was attempted instead.
|
|
2404
|
+
+ `A previous job is still bootstrapped after two attempts to boot it out of ${launchdGuiDomain()}.\n`
|
|
2405
|
+
+ `Inspect it with:\n launchctl print ${bootoutTarget}\n`
|
|
2309
2406
|
// macOS `service repair` delegates straight to installLaunchd, so this fires for
|
|
2310
2407
|
// an already-installed service too; repair reloads it without re-registering.
|
|
2311
2408
|
+ `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
|
|
@@ -3328,6 +3425,7 @@ export function buildUnit(
|
|
|
3328
3425
|
const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
|
|
3329
3426
|
const envLines = [
|
|
3330
3427
|
systemdEnvironmentAssignment("OCX_SERVICE", "1"),
|
|
3428
|
+
systemdEnvironmentAssignment(SERVICE_MANAGED_ENV, "1"),
|
|
3331
3429
|
...(launcher ? [] : [
|
|
3332
3430
|
systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
|
|
3333
3431
|
systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
|
|
@@ -3860,10 +3958,31 @@ export async function installFreshWindowsSchedulerSafely(
|
|
|
3860
3958
|
export function installedServiceRespawnRisk(
|
|
3861
3959
|
probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
|
|
3862
3960
|
platform: NodeJS.Platform = process.platform,
|
|
3863
|
-
|
|
3961
|
+
io: { env?: NodeJS.ProcessEnv; exists?: (path: string) => boolean } = {},
|
|
3962
|
+
): "none" | "respawnable" | "unknown" | "self-unload" {
|
|
3864
3963
|
// launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
|
|
3865
3964
|
// wrapper survives its task ending (#764).
|
|
3866
|
-
|
|
3965
|
+
//
|
|
3966
|
+
// "Down when they report stopped" answers the RESPAWN question but not the SELF-UNLOAD
|
|
3967
|
+
// one (#4023). When the proxy is itself the managed job, `launchctl unload` /
|
|
3968
|
+
// `systemctl stop` terminate this very process, so the manager stop can kill the request
|
|
3969
|
+
// handler before the shared teardown restores the native Codex config keys — leaving
|
|
3970
|
+
// `openai_base_url`, `experimental_realtime_ws_base_url` and `model_catalog_json`
|
|
3971
|
+
// pointed at a proxy that is gone. Reordering teardown ahead of the manager stop is not
|
|
3972
|
+
// available here: the #3008 contract requires the manager to be proven stopped first.
|
|
3973
|
+
// So refuse, exactly as Windows does, and send the operator to `ocx stop`, which stops
|
|
3974
|
+
// the proxy from the outside and owns the teardown through its receipt.
|
|
3975
|
+
if (platform !== "win32") {
|
|
3976
|
+
const env = io.env ?? process.env;
|
|
3977
|
+
// Discriminate on the wrapper-only marker, not on OCX_SERVICE: `ocx claude` and
|
|
3978
|
+
// `ocx opencode` set OCX_SERVICE=1 on the proxies they spawn (for preserveRouting),
|
|
3979
|
+
// and refusing their dashboard stop would break a proxy that no manager supervises.
|
|
3980
|
+
if (env[SERVICE_MANAGED_ENV] !== "1") return "none";
|
|
3981
|
+
const exists = io.exists ?? existsSync;
|
|
3982
|
+
if (platform === "darwin") return exists(plistPath()) ? "self-unload" : "none";
|
|
3983
|
+
if (platform === "linux") return exists(unitPath()) ? "self-unload" : "none";
|
|
3984
|
+
return "none";
|
|
3985
|
+
}
|
|
3867
3986
|
try {
|
|
3868
3987
|
// `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
|
|
3869
3988
|
// fail — it does not throw — so testing for "present" let an unanswerable probe
|
package/src/storage/cleanup.ts
CHANGED
|
@@ -96,6 +96,7 @@ export interface CleanupResult {
|
|
|
96
96
|
trashDir?: string;
|
|
97
97
|
error?: CleanupErrorCode;
|
|
98
98
|
removedPaths: string[];
|
|
99
|
+
skippedReferencedPaths?: string[];
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
const STATE_DB_FILE = /^state_(\d+)\.sqlite$/;
|
|
@@ -684,52 +685,59 @@ function loadMatchingThreads(db: Database, candidates: ArchivedCandidate[], code
|
|
|
684
685
|
}
|
|
685
686
|
|
|
686
687
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* Throws real DB errors (busy/corruption) so callers can refuse cleanup.
|
|
688
|
+
* Partition matched threads into deletable and referenced snapshots. Linked spawn/fork
|
|
689
|
+
* history and paginated histories stay in the skipped set. Throws real DB errors.
|
|
690
690
|
*/
|
|
691
|
-
function
|
|
691
|
+
function filterReferencedHistory(
|
|
692
692
|
db: Database,
|
|
693
693
|
threads: ThreadSnapshot[],
|
|
694
|
-
):
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
694
|
+
): { safe: ThreadSnapshot[]; skipped: ThreadSnapshot[] } {
|
|
695
|
+
let safe = threads.filter(t => (t.history_mode ?? "").toLowerCase() !== "paginated");
|
|
696
|
+
const skipped = new Map(threads
|
|
697
|
+
.filter(t => (t.history_mode ?? "").toLowerCase() === "paginated")
|
|
698
|
+
.map(t => [t.id, t]));
|
|
699
|
+
|
|
700
|
+
while (safe.length > 0) {
|
|
701
|
+
const idSet = new Set(safe.map(t => t.id));
|
|
702
|
+
const unsafeIds = new Set<string>();
|
|
703
|
+
|
|
704
|
+
// Spawn edges that cross the delete boundary keep history reachable.
|
|
705
|
+
if (tableExists(db, "thread_spawn_edges")) {
|
|
706
|
+
for (const chunk of chunkIds([...idSet], SQLITE_ID_CHUNK)) {
|
|
707
707
|
const placeholders = chunk.map(() => "?").join(",");
|
|
708
708
|
const edges = db.query<{ parent_thread_id: string; child_thread_id: string }, string[]>(
|
|
709
709
|
`SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges
|
|
710
710
|
WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
|
|
711
711
|
).all(...chunk, ...chunk);
|
|
712
712
|
for (const edge of edges) {
|
|
713
|
-
if (!idSet.has(edge.parent_thread_id)
|
|
714
|
-
|
|
713
|
+
if (!idSet.has(edge.parent_thread_id)) unsafeIds.add(edge.child_thread_id);
|
|
714
|
+
if (!idSet.has(edge.child_thread_id)) unsafeIds.add(edge.parent_thread_id);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Other threads that list one of ours as forked_from / parent (when columns exist).
|
|
720
|
+
for (const column of ["forked_from_id", "parent_thread_id", "source_thread_id"] as const) {
|
|
721
|
+
if (!columnExists(db, "threads", column)) continue;
|
|
722
|
+
for (const chunk of chunkIds([...idSet], SQLITE_ID_CHUNK * 2)) {
|
|
723
|
+
const placeholders = chunk.map(() => "?").join(",");
|
|
724
|
+
const rows = db.query<{ id: string; ref: string }, string[]>(
|
|
725
|
+
`SELECT id, ${column} AS ref FROM threads WHERE ${column} IN (${placeholders})`,
|
|
726
|
+
).all(...chunk);
|
|
727
|
+
for (const row of rows) {
|
|
728
|
+
if (!idSet.has(row.id)) unsafeIds.add(row.ref);
|
|
715
729
|
}
|
|
716
730
|
}
|
|
717
731
|
}
|
|
718
|
-
}
|
|
719
732
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK * 2)) {
|
|
724
|
-
const placeholders = chunk.map(() => "?").join(",");
|
|
725
|
-
const rows = db.query<{ id: string }, string[]>(
|
|
726
|
-
`SELECT id FROM threads WHERE ${column} IN (${placeholders})`,
|
|
727
|
-
).all(...chunk);
|
|
728
|
-
if (rows.some(r => !idSet.has(r.id))) return true;
|
|
733
|
+
if (unsafeIds.size === 0) break;
|
|
734
|
+
for (const thread of safe) {
|
|
735
|
+
if (unsafeIds.has(thread.id)) skipped.set(thread.id, thread);
|
|
729
736
|
}
|
|
737
|
+
safe = safe.filter(thread => !unsafeIds.has(thread.id));
|
|
730
738
|
}
|
|
731
739
|
|
|
732
|
-
return
|
|
740
|
+
return { safe, skipped: [...skipped.values()] };
|
|
733
741
|
}
|
|
734
742
|
|
|
735
743
|
function tableExists(db: Database, name: string): boolean {
|
|
@@ -778,6 +786,7 @@ function deleteThreadsAndDependents(db: Database, threadIds: string[]): void {
|
|
|
778
786
|
interface ReconcileOk {
|
|
779
787
|
ok: true;
|
|
780
788
|
threads: ThreadSnapshot[];
|
|
789
|
+
skipped: ThreadSnapshot[];
|
|
781
790
|
}
|
|
782
791
|
interface ReconcileErr {
|
|
783
792
|
ok: false;
|
|
@@ -1463,14 +1472,14 @@ function withWritableDb(
|
|
|
1463
1472
|
}
|
|
1464
1473
|
}
|
|
1465
1474
|
|
|
1466
|
-
/** Load matching archived threads and
|
|
1475
|
+
/** Load matching archived threads and retain referenced history — no deletes yet. */
|
|
1467
1476
|
function loadThreadsForCleanup(
|
|
1468
1477
|
stateDbPath: string,
|
|
1469
1478
|
candidates: ArchivedCandidate[],
|
|
1470
1479
|
codexHome: string,
|
|
1471
1480
|
busyTimeoutMs: number,
|
|
1472
1481
|
): ReconcileOk | ReconcileErr {
|
|
1473
|
-
if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [] };
|
|
1482
|
+
if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [], skipped: [] };
|
|
1474
1483
|
let db: Database | undefined;
|
|
1475
1484
|
try {
|
|
1476
1485
|
db = openDbWritable(stateDbPath, busyTimeoutMs);
|
|
@@ -1478,10 +1487,8 @@ function loadThreadsForCleanup(
|
|
|
1478
1487
|
if (threads.some(t => Number(t.is_pinned ?? 0) === 1)) {
|
|
1479
1488
|
return { ok: false, error: "pinned_thread" };
|
|
1480
1489
|
}
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
}
|
|
1484
|
-
return { ok: true, threads };
|
|
1490
|
+
const filtered = filterReferencedHistory(db, threads);
|
|
1491
|
+
return { ok: true, threads: filtered.safe, skipped: filtered.skipped };
|
|
1485
1492
|
} catch (error) {
|
|
1486
1493
|
return { ok: false, error: mapDbError(error) };
|
|
1487
1494
|
} finally {
|
|
@@ -1504,7 +1511,7 @@ function reconcileDeletedThreads(
|
|
|
1504
1511
|
stageDir: string,
|
|
1505
1512
|
hooks?: ReconcileTestHooks,
|
|
1506
1513
|
): ReconcileOk | ReconcileErr {
|
|
1507
|
-
if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [] };
|
|
1514
|
+
if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [], skipped: [] };
|
|
1508
1515
|
|
|
1509
1516
|
if (hooks?.beforeReconcileLock) hooks.beforeReconcileLock();
|
|
1510
1517
|
|
|
@@ -1546,7 +1553,7 @@ function reconcileDeletedThreads(
|
|
|
1546
1553
|
stateDb.exec("ROLLBACK");
|
|
1547
1554
|
return { ok: false, error: "pinned_thread" };
|
|
1548
1555
|
}
|
|
1549
|
-
if (
|
|
1556
|
+
if (filterReferencedHistory(stateDb, threads).safe.length !== threads.length) {
|
|
1550
1557
|
stateDb.exec("ROLLBACK");
|
|
1551
1558
|
return { ok: false, error: "referenced_history" };
|
|
1552
1559
|
}
|
|
@@ -1582,7 +1589,7 @@ function reconcileDeletedThreads(
|
|
|
1582
1589
|
if (hooks?.afterSatelliteMutations) hooks.afterSatelliteMutations();
|
|
1583
1590
|
|
|
1584
1591
|
// Re-check under the same lock before committing state deletes.
|
|
1585
|
-
if (
|
|
1592
|
+
if (filterReferencedHistory(stateDb, threads).safe.length !== threads.length) {
|
|
1586
1593
|
stateDb.exec("ROLLBACK");
|
|
1587
1594
|
return failWithRestore("referenced_history");
|
|
1588
1595
|
}
|
|
@@ -1590,7 +1597,7 @@ function reconcileDeletedThreads(
|
|
|
1590
1597
|
if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit");
|
|
1591
1598
|
stateDb.exec("COMMIT");
|
|
1592
1599
|
// Keep satellite-backup.json for quarantine restore; permanent purge removes the stage.
|
|
1593
|
-
return { ok: true, threads };
|
|
1600
|
+
return { ok: true, threads, skipped: [] };
|
|
1594
1601
|
} catch (error) {
|
|
1595
1602
|
if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
|
|
1596
1603
|
throw error;
|
|
@@ -1895,7 +1902,30 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1895
1902
|
const normalized = normalizeArchivedRolloutPath(thread.rollout_path, codexHome);
|
|
1896
1903
|
if (normalized) threadByRelPath.set(normalized, thread);
|
|
1897
1904
|
}
|
|
1898
|
-
const
|
|
1905
|
+
const skippedReferencedPaths = loaded.skipped
|
|
1906
|
+
.map(thread => normalizeArchivedRolloutPath(thread.rollout_path, codexHome))
|
|
1907
|
+
.filter((path): path is string => path !== null);
|
|
1908
|
+
const matchedPaths = new Set([
|
|
1909
|
+
...threadByRelPath.keys(),
|
|
1910
|
+
...skippedReferencedPaths,
|
|
1911
|
+
]);
|
|
1912
|
+
const candidates = preview.candidates.filter(candidate => {
|
|
1913
|
+
return !matchedPaths.has(candidate.relPath) || threadByRelPath.has(candidate.relPath);
|
|
1914
|
+
});
|
|
1915
|
+
if (candidates.length === 0) {
|
|
1916
|
+
removeStageIfEmpty(stageDir, []);
|
|
1917
|
+
removeEmptyTrashRoot(codexHome);
|
|
1918
|
+
return {
|
|
1919
|
+
ok: true,
|
|
1920
|
+
mode,
|
|
1921
|
+
percent,
|
|
1922
|
+
count: 0,
|
|
1923
|
+
bytes: 0,
|
|
1924
|
+
removedPaths: [],
|
|
1925
|
+
...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
const manifestEntries: CleanupManifestEntry[] = candidates.map(candidate => {
|
|
1899
1929
|
const thread = threadByRelPath.get(candidate.relPath);
|
|
1900
1930
|
return {
|
|
1901
1931
|
relPath: candidate.relPath,
|
|
@@ -1936,7 +1966,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1936
1966
|
return fail(mode, percent, "fs_failed");
|
|
1937
1967
|
}
|
|
1938
1968
|
|
|
1939
|
-
const stageResult = stageCandidates(codexHome,
|
|
1969
|
+
const stageResult = stageCandidates(codexHome, candidates, stageDir, {
|
|
1940
1970
|
blockDestBasenames: blockStageDest.size > 0 ? blockStageDest : undefined,
|
|
1941
1971
|
});
|
|
1942
1972
|
if (!stageResult.ok) {
|
|
@@ -1956,7 +1986,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1956
1986
|
|
|
1957
1987
|
const deleted = reconcileDeletedThreads(
|
|
1958
1988
|
paths,
|
|
1959
|
-
|
|
1989
|
+
candidates,
|
|
1960
1990
|
codexHome,
|
|
1961
1991
|
busyTimeoutMs,
|
|
1962
1992
|
stageDir,
|
|
@@ -1973,8 +2003,8 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1973
2003
|
return fail(mode, percent, deleted.error, keepTrash ? { trashDir } : undefined);
|
|
1974
2004
|
}
|
|
1975
2005
|
|
|
1976
|
-
const removedPaths =
|
|
1977
|
-
const bytes =
|
|
2006
|
+
const removedPaths = candidates.map(c => c.relPath);
|
|
2007
|
+
const bytes = candidates.reduce((sum, c) => sum + c.bytes, 0);
|
|
1978
2008
|
|
|
1979
2009
|
if (mode === "quarantine") {
|
|
1980
2010
|
return {
|
|
@@ -1985,6 +2015,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
1985
2015
|
bytes,
|
|
1986
2016
|
trashDir,
|
|
1987
2017
|
removedPaths,
|
|
2018
|
+
...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
|
|
1988
2019
|
};
|
|
1989
2020
|
}
|
|
1990
2021
|
|
|
@@ -2038,6 +2069,7 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
|
|
|
2038
2069
|
count: removedPaths.length,
|
|
2039
2070
|
bytes,
|
|
2040
2071
|
removedPaths,
|
|
2072
|
+
...(skippedReferencedPaths.length ? { skippedReferencedPaths } : {}),
|
|
2041
2073
|
};
|
|
2042
2074
|
}
|
|
2043
2075
|
|
package/src/types/accounts.ts
CHANGED
|
@@ -34,4 +34,22 @@ export interface CodexAccountCredentialRecord {
|
|
|
34
34
|
lastCodexValidatedAt?: number;
|
|
35
35
|
lastCodexValidationStatus?: "ok" | "failed";
|
|
36
36
|
lastCodexValidationError?: string;
|
|
37
|
+
/** OAuth succeeded while quota was exhausted; never route until deferred validation succeeds. */
|
|
38
|
+
codexValidationPending?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Set when the recorded failure is TERMINAL: the OAuth grant itself was revoked or has
|
|
41
|
+
* expired, so no retry can recover it and only a re-login will. It distinguishes a dead
|
|
42
|
+
* credential from a transient warmup or probe failure that may clear on its own.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately a separate optional key rather than a third value in
|
|
45
|
+
* `lastCodexValidationStatus`: `isCredentialRecord` admits only `"ok" | "failed"`, so a
|
|
46
|
+
* record carrying an unrecognized status fails validation and is DROPPED from the store
|
|
47
|
+
* on load. An unknown extra key is carried through untouched instead, which keeps a
|
|
48
|
+
* downgrade from deleting the account entry and its credential.
|
|
49
|
+
*
|
|
50
|
+
* Cleared by `markCodexAccountValidated` and — because it is absent from
|
|
51
|
+
* `preservedValidationMetadata` — by every credential write. A refresh that succeeds
|
|
52
|
+
* disproves "the grant was revoked", so the verdict must not outlive it.
|
|
53
|
+
*/
|
|
54
|
+
lastCodexValidationTerminal?: boolean;
|
|
37
55
|
}
|