@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901
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 +127 -16
- package/gui/dist/assets/index-6HZDkIpX.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
- package/gui/dist/provider-icons/openclaw.svg +54 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +423 -32
- package/src/cli/integrations.ts +24 -2
- package/src/cli/launcher-context.ts +53 -2
- package/src/cli/opencode.ts +126 -33
- package/src/cli/registry.ts +16 -10
- package/src/cli/system-command.ts +6 -1
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +131 -12
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +96 -10
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/cli-install-provenance.ts +795 -0
- package/src/codex/convergence.ts +4 -3
- package/src/codex/credential-mutation-epoch.ts +11 -0
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +430 -27
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/routing.ts +68 -16
- package/src/codex/shim.ts +204 -18
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +155 -67
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +107 -16
- package/src/lib/bounded-subprocess.ts +36 -0
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/lib/strict-semver.ts +47 -0
- package/src/lib/windows-elevation.ts +32 -1
- package/src/lib/windows-secret-acl.ts +47 -25
- package/src/lib/windows-service-mutation-lock.ts +133 -0
- package/src/lib/windows-user-principal.ts +15 -17
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +354 -29
- package/src/responses/state.ts +775 -8
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management/model-rows.ts +11 -2
- package/src/server/management/provider-routes.ts +4 -0
- package/src/server/management/system-restart.ts +5 -5
- package/src/server/management-api.ts +72 -13
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/startup-action-control.ts +3 -2
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +749 -53
- package/src/sidecar/candidates.ts +1 -1
- package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +10 -11
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-CowztZdo.js +0 -112
- package/gui/dist/assets/index-jqE_VOKI.css +0 -1
package/src/config/paths.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { chmodSync, existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { hardenSecretDirAsync, windowsSecretAclApplies } from "../lib/windows-secret-acl";
|
|
5
5
|
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -14,6 +14,7 @@ export function expandUserPath(raw: string): string {
|
|
|
14
14
|
return raw;
|
|
15
15
|
}
|
|
16
16
|
let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
|
|
17
|
+
const configDirHardeningFlights = new Map<string, Promise<void>>();
|
|
17
18
|
|
|
18
19
|
export function getConfigDir(): string {
|
|
19
20
|
const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
|
|
@@ -34,7 +35,21 @@ export function hardenConfigDir(): void {
|
|
|
34
35
|
assertNotRealHomeUnderTest(dir);
|
|
35
36
|
if (!existsSync(dir)) return;
|
|
36
37
|
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
37
|
-
if (
|
|
38
|
-
|
|
38
|
+
if (windowsSecretAclApplies() && !configDirHardeningFlights.has(dir)) {
|
|
39
|
+
// This is an optional read-path harden. Waiting synchronously here used to stop the Bun
|
|
40
|
+
// event loop (including /healthz) for the full icacls timeout. Required mutation paths keep
|
|
41
|
+
// their own awaited/fail-closed hardening; ordinary config reads only start one soft flight.
|
|
42
|
+
const flight = hardenSecretDirAsync(dir, { required: false })
|
|
43
|
+
.then(() => undefined)
|
|
44
|
+
.catch(() => undefined)
|
|
45
|
+
.finally(() => {
|
|
46
|
+
if (configDirHardeningFlights.get(dir) === flight) configDirHardeningFlights.delete(dir);
|
|
47
|
+
});
|
|
48
|
+
configDirHardeningFlights.set(dir, flight);
|
|
39
49
|
}
|
|
40
50
|
}
|
|
51
|
+
|
|
52
|
+
/** Test-only: settle optional config-directory hardening without exposing it to production callers. */
|
|
53
|
+
export async function flushConfigDirHardeningForTests(): Promise<void> {
|
|
54
|
+
await Promise.all([...configDirHardeningFlights.values()]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const PENDING_TEARDOWN_PREFIX: string;
|
|
2
|
+
export declare const PENDING_TEARDOWN_SUFFIX: string;
|
|
3
|
+
export declare const PENDING_TEARDOWN_UNREADABLE_SUFFIX: string;
|
|
4
|
+
export declare function isPendingTeardownFileName(name: unknown): boolean;
|
|
5
|
+
export declare function isQuarantinedTeardownFileName(name: unknown): boolean;
|
|
6
|
+
export declare function isAnyTeardownObligationFileName(name: unknown): boolean;
|
|
7
|
+
export declare function pendingTeardownNonceFromFileName(name: string): string | null;
|
|
8
|
+
export declare function hasPendingTeardownIn(readdir: (dir: string) => string[], dir: string): boolean;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming rules for pending-teardown receipts, shared by both update lanes (#3008).
|
|
3
|
+
*
|
|
4
|
+
* Plain ESM because `bin/ocx.mjs` runs under Node before Bun exists and cannot import the
|
|
5
|
+
* TypeScript module. It lives here rather than being spelled out twice because that is
|
|
6
|
+
* exactly how this broke: the launcher kept checking the retired singleton filename after
|
|
7
|
+
* the receipts moved to one file per claim, so the npm lane silently stopped seeing every
|
|
8
|
+
* outstanding obligation.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const PENDING_TEARDOWN_PREFIX = "pending-teardown-";
|
|
12
|
+
export const PENDING_TEARDOWN_SUFFIX = ".json";
|
|
13
|
+
/**
|
|
14
|
+
* Suffix for an obligation that could not be read.
|
|
15
|
+
*
|
|
16
|
+
* It is still an obligation. Quarantine renames the file so the ordinary recovery loop
|
|
17
|
+
* stops re-reading garbage, but it must NOT stop counting: an update that proceeds
|
|
18
|
+
* because the evidence was filed away is exactly the outcome the receipt exists to
|
|
19
|
+
* prevent. Both lanes treat this as outstanding until an operator removes it.
|
|
20
|
+
*/
|
|
21
|
+
export const PENDING_TEARDOWN_UNREADABLE_SUFFIX = ".unreadable.json";
|
|
22
|
+
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
23
|
+
|
|
24
|
+
/** A receipt the recovery loop should read and try to discharge. */
|
|
25
|
+
export function isPendingTeardownFileName(name) {
|
|
26
|
+
if (typeof name !== "string") return false;
|
|
27
|
+
if (isQuarantinedTeardownFileName(name)) return false;
|
|
28
|
+
if (!name.startsWith(PENDING_TEARDOWN_PREFIX) || !name.endsWith(PENDING_TEARDOWN_SUFFIX)) return false;
|
|
29
|
+
return NONCE_RE.test(name.slice(PENDING_TEARDOWN_PREFIX.length, name.length - PENDING_TEARDOWN_SUFFIX.length));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A receipt that could not be read and is waiting on a human. */
|
|
33
|
+
export function isQuarantinedTeardownFileName(name) {
|
|
34
|
+
if (typeof name !== "string") return false;
|
|
35
|
+
if (!name.startsWith(PENDING_TEARDOWN_PREFIX) || !name.endsWith(PENDING_TEARDOWN_UNREADABLE_SUFFIX)) return false;
|
|
36
|
+
return NONCE_RE.test(name.slice(
|
|
37
|
+
PENDING_TEARDOWN_PREFIX.length,
|
|
38
|
+
name.length - PENDING_TEARDOWN_UNREADABLE_SUFFIX.length,
|
|
39
|
+
));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Any obligation at all — readable or quarantined. Both block an update. */
|
|
43
|
+
export function isAnyTeardownObligationFileName(name) {
|
|
44
|
+
return isPendingTeardownFileName(name) || isQuarantinedTeardownFileName(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function pendingTeardownNonceFromFileName(name) {
|
|
48
|
+
if (!isPendingTeardownFileName(name)) return null;
|
|
49
|
+
return name.slice(PENDING_TEARDOWN_PREFIX.length, name.length - PENDING_TEARDOWN_SUFFIX.length);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Does the given config directory hold any outstanding obligation?
|
|
54
|
+
*
|
|
55
|
+
* Quarantined receipts count. Filing one away to unblock an update would let the very
|
|
56
|
+
* next `ocx update` install over a teardown that never ran — the enforcement has to
|
|
57
|
+
* survive until a human removes the file.
|
|
58
|
+
*/
|
|
59
|
+
export function hasPendingTeardownIn(readdir, dir) {
|
|
60
|
+
try {
|
|
61
|
+
return readdir(dir).some(isAnyTeardownObligationFileName);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// "There is no home yet" is the only honest empty answer. Any other failure —
|
|
64
|
+
// permissions, I/O, a file where the directory should be — means an obligation may be
|
|
65
|
+
// sitting there unread, and reporting "none" would let an update install over a
|
|
66
|
+
// teardown that never ran. Absence of proof is not proof of absence.
|
|
67
|
+
return error?.code !== "ENOENT";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
|
|
5
|
+
import { atomicWriteFile } from "./atomic-write";
|
|
6
|
+
import { getConfigDir } from "./paths";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ownership receipt for a deferred shared teardown (#3008).
|
|
10
|
+
*
|
|
11
|
+
* `ocx stop` asks the proxy NOT to restore native Codex and the Grok fence, because a
|
|
12
|
+
* stopped Task Scheduler can respawn the proxy and a survivor must keep its client
|
|
13
|
+
* config. That hands one obligation to the parent — and a bare query flag cannot express
|
|
14
|
+
* an obligation: if the parent dies between the child's exit and its own restore, the
|
|
15
|
+
* shared config keeps pointing at a proxy that is gone, with nothing on disk saying so.
|
|
16
|
+
*
|
|
17
|
+
* The receipt is that missing state. The parent writes it BEFORE asking for a deferred
|
|
18
|
+
* stop and removes it only after its own restore, so a later `ocx stop`/`ocx update` can
|
|
19
|
+
* see the abandoned obligation and finish it once that proxy is proven down.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the nonce is the FILENAME
|
|
22
|
+
*
|
|
23
|
+
* One shared file cannot be cleared safely. Read-compare-unlink is three syscalls, and a
|
|
24
|
+
* concurrent stop replacing the file between the compare and the unlink means this run
|
|
25
|
+
* deletes an obligation it never owned — the check passed against bytes that are already
|
|
26
|
+
* gone. Giving each claim its own path removes the race rather than serializing it:
|
|
27
|
+
* `unlink` names one specific obligation, so it can only ever delete that one. Two
|
|
28
|
+
* concurrent stops hold two receipts, which is the truth of the situation.
|
|
29
|
+
*/
|
|
30
|
+
export type PendingTeardownReceipt = {
|
|
31
|
+
/** Process that accepted the obligation, so a live owner is distinguishable from a dead one. */
|
|
32
|
+
ownerPid: number;
|
|
33
|
+
/** Identity of this claim; also its filename, which is what makes a clear a single-syscall delete. */
|
|
34
|
+
nonce: string;
|
|
35
|
+
/** ISO timestamp, for diagnostics only; recovery is decided by liveness, not by age. */
|
|
36
|
+
createdAt: string;
|
|
37
|
+
/**
|
|
38
|
+
* Endpoint the owner was stopping.
|
|
39
|
+
*
|
|
40
|
+
* Recovery has to prove THAT proxy is down, and after a crash the runtime-port record
|
|
41
|
+
* is usually gone. Falling back to the configured port asks the wrong question for a
|
|
42
|
+
* proxy started with an explicit `--port`: the configured port refuses while the live
|
|
43
|
+
* one keeps serving, and its client config gets torn out from under it.
|
|
44
|
+
*/
|
|
45
|
+
endpoint: { hostname: string; port: number };
|
|
46
|
+
/**
|
|
47
|
+
* How the endpoint was obtained.
|
|
48
|
+
*
|
|
49
|
+
* `exact` came from the runtime record or a successful liveness probe — the address the
|
|
50
|
+
* stop actually contacted. `guessed` is the configured listen address, recorded because
|
|
51
|
+
* an obligation with a weak address beats no obligation at all, but it is NOT evidence:
|
|
52
|
+
* a proxy on an explicit `--port` can be respawned there while the configured port
|
|
53
|
+
* refuses, and treating that refusal as proof would restore under a live proxy. A
|
|
54
|
+
* guessed receipt therefore fails closed into manual recovery.
|
|
55
|
+
*/
|
|
56
|
+
endpointSource: "exact" | "guessed";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What is on disk, kept distinct from what it means.
|
|
61
|
+
*
|
|
62
|
+
* Collapsing a malformed file into "no receipt" loses the one fact recovery needs: an
|
|
63
|
+
* obligation may still be outstanding, and its owner can no longer be identified. That
|
|
64
|
+
* state must not silently authorize a deferral, and it must not wedge every later stop
|
|
65
|
+
* either — see {@link quarantinePendingTeardown}.
|
|
66
|
+
*/
|
|
67
|
+
export type PendingTeardownRead =
|
|
68
|
+
| { state: "missing" }
|
|
69
|
+
| { state: "valid"; receipt: PendingTeardownReceipt }
|
|
70
|
+
| { state: "invalid"; nonce: string; detail: string };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The home itself could not be listed.
|
|
74
|
+
*
|
|
75
|
+
* Distinct from an invalid receipt: there is no file to quarantine and no nonce to name,
|
|
76
|
+
* so it must never be fed to the receipt machinery. It blocks like any obligation, but the
|
|
77
|
+
* remedy is to fix the directory and retry, not to remove something.
|
|
78
|
+
*/
|
|
79
|
+
export type TeardownScanFailure = { state: "unscannable"; detail: string };
|
|
80
|
+
|
|
81
|
+
import {
|
|
82
|
+
isPendingTeardownFileName,
|
|
83
|
+
isAnyTeardownObligationFileName,
|
|
84
|
+
PENDING_TEARDOWN_PREFIX as PREFIX,
|
|
85
|
+
PENDING_TEARDOWN_SUFFIX as SUFFIX,
|
|
86
|
+
PENDING_TEARDOWN_UNREADABLE_SUFFIX as UNREADABLE_SUFFIX,
|
|
87
|
+
pendingTeardownNonceFromFileName,
|
|
88
|
+
} from "./pending-teardown-names.mjs";
|
|
89
|
+
|
|
90
|
+
const NONCE_RE = /^[0-9a-f]{32}$/;
|
|
91
|
+
|
|
92
|
+
export function pendingTeardownPathFor(nonce: string): string {
|
|
93
|
+
return join(getConfigDir(), `${PREFIX}${nonce}${SUFFIX}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isReceipt(value: unknown, nonce: string): value is PendingTeardownReceipt {
|
|
97
|
+
if (!value || typeof value !== "object") return false;
|
|
98
|
+
const receipt = value as Record<string, unknown>;
|
|
99
|
+
const endpoint = receipt.endpoint as Record<string, unknown> | undefined;
|
|
100
|
+
const endpointOk = !!endpoint
|
|
101
|
+
&& typeof endpoint === "object"
|
|
102
|
+
&& typeof endpoint.hostname === "string"
|
|
103
|
+
&& endpoint.hostname.trim() !== ""
|
|
104
|
+
&& Number.isInteger(endpoint.port)
|
|
105
|
+
&& Number(endpoint.port) > 0
|
|
106
|
+
&& Number(endpoint.port) <= 65535;
|
|
107
|
+
return Number.isSafeInteger(receipt.ownerPid)
|
|
108
|
+
&& Number(receipt.ownerPid) > 0
|
|
109
|
+
// The body must agree with the name: a receipt whose nonce was edited to name a
|
|
110
|
+
// different claim would let a request authorize a deferral it does not own.
|
|
111
|
+
&& receipt.nonce === nonce
|
|
112
|
+
&& typeof receipt.createdAt === "string"
|
|
113
|
+
&& (receipt.endpointSource === "exact" || receipt.endpointSource === "guessed")
|
|
114
|
+
&& endpointOk;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Claim a deferred teardown for this process. Returns the receipt that was written. */
|
|
118
|
+
export function claimPendingTeardown(
|
|
119
|
+
endpoint: { hostname: string; port: number },
|
|
120
|
+
endpointSource: "exact" | "guessed",
|
|
121
|
+
ownerPid: number = process.pid,
|
|
122
|
+
): PendingTeardownReceipt {
|
|
123
|
+
const dir = getConfigDir();
|
|
124
|
+
assertNotRealHomeUnderTest(dir);
|
|
125
|
+
const nonce = randomBytes(16).toString("hex");
|
|
126
|
+
const receipt: PendingTeardownReceipt = { ownerPid, nonce, createdAt: new Date().toISOString(), endpoint, endpointSource };
|
|
127
|
+
atomicWriteFile(pendingTeardownPathFor(nonce), JSON.stringify(receipt, null, 2) + "\n");
|
|
128
|
+
return receipt;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function readPendingTeardown(nonce: string): PendingTeardownRead {
|
|
132
|
+
if (!NONCE_RE.test(nonce)) return { state: "missing" };
|
|
133
|
+
let raw: string;
|
|
134
|
+
try {
|
|
135
|
+
raw = readFileSync(pendingTeardownPathFor(nonce), "utf-8");
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Only "there is no file" is absence. A permission error, or a directory sitting where
|
|
138
|
+
// the receipt belongs, means something IS there and cannot be read; calling that
|
|
139
|
+
// missing hides an obligation that may still be outstanding.
|
|
140
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
141
|
+
if (code === "ENOENT") return { state: "missing" };
|
|
142
|
+
return { state: "invalid", nonce, detail: `unreadable (${code ?? "unknown"})` };
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const parsed: unknown = JSON.parse(raw);
|
|
146
|
+
if (isReceipt(parsed, nonce)) return { state: "valid", receipt: parsed };
|
|
147
|
+
const digest = createHash("sha256").update(raw).digest("hex").slice(0, 12);
|
|
148
|
+
return { state: "invalid", nonce, detail: `malformed receipt (sha256 ${digest})` };
|
|
149
|
+
} catch {
|
|
150
|
+
return { state: "invalid", nonce, detail: "unparseable JSON" };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** An obligation that exists on disk — the "missing" case cannot occur in a listing. */
|
|
155
|
+
export type OutstandingTeardown = Exclude<PendingTeardownRead, { state: "missing" }> | TeardownScanFailure;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every obligation currently on disk, attributable or not.
|
|
159
|
+
*
|
|
160
|
+
* A scan that FAILS is not an empty scan. Swallowing a permission or I/O error into `[]`
|
|
161
|
+
* would let `handleStop` restore client config with an unread obligation sitting right
|
|
162
|
+
* there, so anything but a missing home surfaces as one unreadable obligation the caller
|
|
163
|
+
* must treat like any other: blocking, and needing a human.
|
|
164
|
+
*/
|
|
165
|
+
export function listPendingTeardowns(): OutstandingTeardown[] {
|
|
166
|
+
let names: string[];
|
|
167
|
+
try {
|
|
168
|
+
names = readdirSync(getConfigDir());
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
171
|
+
// Not an invalid RECEIPT: there is no file here and no nonce to name. Synthesizing one
|
|
172
|
+
// would hand a fabricated identity to the quarantine and clear paths, which could then
|
|
173
|
+
// rename or delete a real receipt that happened to carry it.
|
|
174
|
+
return [{ state: "unscannable", detail: `the opencodex home could not be listed (${(error as NodeJS.ErrnoException).code ?? "unknown"})` }];
|
|
175
|
+
}
|
|
176
|
+
const out: OutstandingTeardown[] = [];
|
|
177
|
+
for (const name of names) {
|
|
178
|
+
// One naming rule, shared with the npm launcher: the two lanes drifting apart is
|
|
179
|
+
// exactly how the Node updater stopped seeing receipts at all.
|
|
180
|
+
if (!isPendingTeardownFileName(name)) continue;
|
|
181
|
+
const nonce = pendingTeardownNonceFromFileName(name)!;
|
|
182
|
+
const read = readPendingTeardown(nonce);
|
|
183
|
+
if (read.state !== "missing") out.push(read);
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Is any obligation outstanding, whether or not it can still be attributed?
|
|
190
|
+
*
|
|
191
|
+
* Quarantined receipts count. Filing an unreadable one away must not let the next update
|
|
192
|
+
* install over a teardown that never ran — that would turn "we could not tell" into "it
|
|
193
|
+
* is fine", which is the failure this whole mechanism exists to prevent.
|
|
194
|
+
*/
|
|
195
|
+
export function pendingTeardownOutstanding(): boolean {
|
|
196
|
+
try {
|
|
197
|
+
return readdirSync(getConfigDir()).some(isAnyTeardownObligationFileName);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// Only a missing home is empty. Any other scan failure may be hiding an obligation,
|
|
200
|
+
// and reporting "none" would unblock an update over a teardown that never ran.
|
|
201
|
+
return (error as NodeJS.ErrnoException).code !== "ENOENT";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Paths of quarantined obligations awaiting a human. */
|
|
206
|
+
export function listQuarantinedTeardowns(): string[] {
|
|
207
|
+
try {
|
|
208
|
+
return readdirSync(getConfigDir())
|
|
209
|
+
.filter(name => name.startsWith(PREFIX) && name.endsWith(UNREADABLE_SUFFIX))
|
|
210
|
+
.map(name => join(getConfigDir(), name));
|
|
211
|
+
} catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Remove exactly one obligation.
|
|
218
|
+
*
|
|
219
|
+
* The nonce is the filename, so this is a compare-and-delete in one syscall: it can never
|
|
220
|
+
* remove a receipt another process wrote, because that receipt lives at a different path.
|
|
221
|
+
* Returns whether the obligation is gone — a failed unlink is reported rather than
|
|
222
|
+
* swallowed, since a receipt that survives its discharge re-triggers recovery forever.
|
|
223
|
+
*/
|
|
224
|
+
export function clearPendingTeardown(nonce: string): boolean {
|
|
225
|
+
try {
|
|
226
|
+
unlinkSync(pendingTeardownPathFor(nonce));
|
|
227
|
+
return true;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Move an unattributable obligation aside.
|
|
235
|
+
*
|
|
236
|
+
* An invalid receipt names no endpoint, so nothing can prove its proxy is down, so it can
|
|
237
|
+
* never be discharged the normal way. Left in place it is not merely useless: both
|
|
238
|
+
* updater gates treat an outstanding receipt as a reason to run the stop, and that stop
|
|
239
|
+
* would fail on the same receipt every time — an update that can never proceed.
|
|
240
|
+
*
|
|
241
|
+
* Renaming stops the recovery loop from re-reading garbage on every stop, but it
|
|
242
|
+
* deliberately does NOT stop the obligation from counting: `pendingTeardownOutstanding`
|
|
243
|
+
* still sees it, so both updaters keep refusing to install over a teardown that never
|
|
244
|
+
* ran. Only a human removing the file ends the enforcement.
|
|
245
|
+
*
|
|
246
|
+
* Returns the path it was moved to, or null when it could not be moved.
|
|
247
|
+
*/
|
|
248
|
+
export function quarantinePendingTeardown(nonce: string): string | null {
|
|
249
|
+
const from = pendingTeardownPathFor(nonce);
|
|
250
|
+
if (!existsSync(from)) return null;
|
|
251
|
+
const to = join(getConfigDir(), `${PREFIX}${nonce}${UNREADABLE_SUFFIX}`);
|
|
252
|
+
try {
|
|
253
|
+
renameSync(from, to);
|
|
254
|
+
return to;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* True when a previous deferred stop left its obligation unfinished.
|
|
262
|
+
*
|
|
263
|
+
* A receipt whose owner is still alive belongs to a stop that is still running: leave it
|
|
264
|
+
* alone. Only an abandoned obligation is a candidate, and a VALID one still has to prove
|
|
265
|
+
* its endpoint is down before anything is restored — an invalid one never can, which is
|
|
266
|
+
* what {@link quarantinePendingTeardown} exists for.
|
|
267
|
+
*/
|
|
268
|
+
export function isPendingTeardownAbandoned(
|
|
269
|
+
read: PendingTeardownRead | TeardownScanFailure,
|
|
270
|
+
isAlive: (pid: number) => boolean,
|
|
271
|
+
selfPid: number = process.pid,
|
|
272
|
+
): boolean {
|
|
273
|
+
if (read.state === "missing") return false;
|
|
274
|
+
// A home that cannot be listed may be hiding an obligation. It is not recoverable and
|
|
275
|
+
// not removable; the caller blocks on it and asks for the directory to be fixed.
|
|
276
|
+
if (read.state === "unscannable") return true;
|
|
277
|
+
if (read.state === "invalid") return true;
|
|
278
|
+
if (read.receipt.ownerPid === selfPid) return false;
|
|
279
|
+
return !isAlive(read.receipt.ownerPid);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Does this request name an obligation that exists and is readable? */
|
|
283
|
+
export function deferralMatchesReceipt(nonce: string | null): boolean {
|
|
284
|
+
if (!nonce || !NONCE_RE.test(nonce)) return false;
|
|
285
|
+
return readPendingTeardown(nonce).state === "valid";
|
|
286
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -2380,6 +2380,29 @@ function configMutationDatabasePath(): string {
|
|
|
2380
2380
|
return path;
|
|
2381
2381
|
}
|
|
2382
2382
|
|
|
2383
|
+
/** Raised when an independent config-mutation transaction is requested recursively. */
|
|
2384
|
+
export class NestedConfigMutationError extends Error {
|
|
2385
|
+
constructor() {
|
|
2386
|
+
super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync");
|
|
2387
|
+
this.name = "NestedConfigMutationError";
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
/**
|
|
2392
|
+
* Prepare the shared config-mutation database path for an independent top-level
|
|
2393
|
+
* SQLite transaction. Callers must not invoke this while holding
|
|
2394
|
+
* {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately
|
|
2395
|
+
* fails busy instead of joining an uncommitted transaction.
|
|
2396
|
+
*
|
|
2397
|
+
* @throws {NestedConfigMutationError} If a config mutation lock is already held.
|
|
2398
|
+
*/
|
|
2399
|
+
export function prepareConfigMutationDatabasePathForWrite(): string {
|
|
2400
|
+
if (configMutationLockDepth > 0) {
|
|
2401
|
+
throw new NestedConfigMutationError();
|
|
2402
|
+
}
|
|
2403
|
+
return configMutationDatabasePath();
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2383
2406
|
let configMutationLockDepth = 0;
|
|
2384
2407
|
let configMutationDatabase: Database | null = null;
|
|
2385
2408
|
|