@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
|
@@ -19,7 +19,18 @@ import { atomicWriteFile } from "../config";
|
|
|
19
19
|
import { ensureDir, fingerprint, integrationsDir, type OwnershipRecord } from "./ownership";
|
|
20
20
|
import { isIntegrationClientId, type IntegrationClientId } from "./registry";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* `overwrite` is deliberately distinct from `apply`. Both write our block, but
|
|
24
|
+
* only one of them replaced something the user or another tool had put there,
|
|
25
|
+
* and the rollback list is exactly where that distinction matters.
|
|
26
|
+
*
|
|
27
|
+
* This union is re-declared, not imported, in two other places -- the management
|
|
28
|
+
* route envelope and the GUI adapter -- because neither imports across that
|
|
29
|
+
* boundary. `tests/integrations-journal.test.ts` asserts the three agree, since
|
|
30
|
+
* nothing else can: a kind persisted here and missing there renders as a raw
|
|
31
|
+
* key with no type error anywhere.
|
|
32
|
+
*/
|
|
33
|
+
export type OperationKind = "apply" | "disable" | "refresh" | "restore" | "overwrite";
|
|
23
34
|
|
|
24
35
|
/**
|
|
25
36
|
* Tagged so "the file did not exist" and "the snapshot was collected" stay
|
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import {
|
|
14
|
+
ClientPathError,
|
|
14
15
|
EXPORT_CLIENTS,
|
|
16
|
+
asideAccountDir,
|
|
17
|
+
asideConfigPath,
|
|
18
|
+
asideHomeDir,
|
|
15
19
|
dshConfigPath,
|
|
16
20
|
dshHomeDir,
|
|
17
21
|
gajaeConfigPath,
|
|
@@ -52,6 +56,86 @@ export interface IntegrationClientSpec {
|
|
|
52
56
|
sourcePreservingYaml?: { path: readonly string[] };
|
|
53
57
|
/** Coordinate the complete mutation through a sibling config lock. */
|
|
54
58
|
writerLock?: { suffix: ".lock" };
|
|
59
|
+
/**
|
|
60
|
+
* Derive the config path AND the detect directory from one resolution, for a
|
|
61
|
+
* client whose paths depend on mutable state rather than only env and home.
|
|
62
|
+
*
|
|
63
|
+
* Only Aside needs this. Its two paths both come from the account id in
|
|
64
|
+
* `accounts.json`, so calling `configPath` and `detectDir` in sequence can
|
|
65
|
+
* straddle an account switch and check one account's install while writing
|
|
66
|
+
* another's catalog. Reading the id once and deriving both paths from it
|
|
67
|
+
* removes the window instead of narrowing it.
|
|
68
|
+
*/
|
|
69
|
+
resolvePaths?: (env?: NodeJS.ProcessEnv, home?: string) => { configPath: string; detectDir: string };
|
|
70
|
+
/**
|
|
71
|
+
* Where the client's config WOULD live, for a client whose real path cannot
|
|
72
|
+
* be resolved yet.
|
|
73
|
+
*
|
|
74
|
+
* Only a client with `resolvePaths` needs this, and only because that
|
|
75
|
+
* resolution can legitimately fail on a machine where the client has never
|
|
76
|
+
* run. Aside's account id comes from a manifest the app writes at first
|
|
77
|
+
* launch, so a never-signed-in install has no account directory and no id --
|
|
78
|
+
* which is "not installed", not "we cannot verify this file".
|
|
79
|
+
*
|
|
80
|
+
* The value is a location to SHOW, never a location to write: it names the
|
|
81
|
+
* account root without an account, so it cannot be mistaken for a real
|
|
82
|
+
* catalog. `resolveIntegrationPaths` still throws for callers that mutate.
|
|
83
|
+
*/
|
|
84
|
+
unresolvedPathHint?: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The one place that turns a client id into the pair of paths an operation uses.
|
|
89
|
+
*
|
|
90
|
+
* A caller that resolves `configPath` and `detectDir` separately is correct for
|
|
91
|
+
* every client whose paths are a pure function of env and home, and wrong for
|
|
92
|
+
* one that reads mutable state. Routing both through here lets such a client fix
|
|
93
|
+
* that for itself without every call site learning why.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveIntegrationPaths(
|
|
96
|
+
clientId: IntegrationClientId,
|
|
97
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
98
|
+
home: string = homedir(),
|
|
99
|
+
): { configPath: string; detectDir: string } {
|
|
100
|
+
const spec = INTEGRATION_CLIENTS[clientId];
|
|
101
|
+
if (spec.resolvePaths) return spec.resolvePaths(env, home);
|
|
102
|
+
return { configPath: spec.configPath(env, home), detectDir: spec.detectDir(env, home) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The location to name when resolution refused, or `""` when there is none.
|
|
107
|
+
*
|
|
108
|
+
* A read-only surface reporting "unresolvable" with an empty path told the user
|
|
109
|
+
* nothing they could act on, and for Aside it also reported the wrong thing: an
|
|
110
|
+
* absent account manifest is the ordinary state of an installed-but-never-run
|
|
111
|
+
* Aside, and the honest answer there is that it is not signed in.
|
|
112
|
+
*
|
|
113
|
+
* `""` is a sentinel, not a path: it is what `readIntegrationState` reads to
|
|
114
|
+
* decide between not-installed and cannot-verify. A config path is never
|
|
115
|
+
* legitimately empty, and a hint is always an absolute `join` result, so the two
|
|
116
|
+
* cannot be confused.
|
|
117
|
+
*/
|
|
118
|
+
export function unresolvedPathHintFor(
|
|
119
|
+
clientId: IntegrationClientId,
|
|
120
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
121
|
+
home: string = homedir(),
|
|
122
|
+
): string {
|
|
123
|
+
const spec = INTEGRATION_CLIENTS[clientId];
|
|
124
|
+
if (!spec.unresolvedPathHint) return "";
|
|
125
|
+
try {
|
|
126
|
+
return spec.unresolvedPathHint(env, home);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
/*
|
|
129
|
+
* Only a path refusal is absorbed. An unqualified catch here would also
|
|
130
|
+
* swallow a TypeError from a future implementor's typo, an
|
|
131
|
+
* ERR_INVALID_ARG_TYPE out of `join`, or an EACCES from a resolver that
|
|
132
|
+
* touches the filesystem -- turning a programming error into a silently
|
|
133
|
+
* degraded badge. `readIntegrationState` narrows the same way at its own
|
|
134
|
+
* catch, and this is the matching half.
|
|
135
|
+
*/
|
|
136
|
+
if (!(error instanceof ClientPathError)) throw error;
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
55
139
|
}
|
|
56
140
|
|
|
57
141
|
/**
|
|
@@ -149,6 +233,34 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
|
|
|
149
233
|
// is set. Same choice as OMP, whose detect signal is `ompAgentDir`.
|
|
150
234
|
detectDir: (env = process.env, home = homedir()) => primeAgentDir(env, home),
|
|
151
235
|
},
|
|
236
|
+
aside: {
|
|
237
|
+
id: "aside",
|
|
238
|
+
configPath: (env = process.env, home = homedir()) => asideConfigPath(env, home),
|
|
239
|
+
/*
|
|
240
|
+
* The ACCOUNT directory, not `~/.aside`. Aside's CLI creates `~/.aside/cli`
|
|
241
|
+
* for its own update check before any account exists, so the outer directory
|
|
242
|
+
* is present on a machine that never signed in, and writing a catalog for an
|
|
243
|
+
* account that does not exist is worse than reporting absent.
|
|
244
|
+
*/
|
|
245
|
+
detectDir: (env = process.env, home = homedir()) => asideAccountDir(env, home),
|
|
246
|
+
/*
|
|
247
|
+
* Both paths from ONE account read. The two resolvers above each consult
|
|
248
|
+
* the account manifest, so a switch landing between them would let an
|
|
249
|
+
* operation verify one account's install and then write another's catalog.
|
|
250
|
+
*/
|
|
251
|
+
resolvePaths: (env = process.env, home = homedir()) => {
|
|
252
|
+
const detectDir = asideAccountDir(env, home);
|
|
253
|
+
return { configPath: join(detectDir, "models.json"), detectDir };
|
|
254
|
+
},
|
|
255
|
+
/*
|
|
256
|
+
* The account ROOT, with no account under it. Aside writes `accounts.json`
|
|
257
|
+
* at first launch, so its absence is the ordinary state of an Aside that has
|
|
258
|
+
* been installed and never signed into -- and a page that answered "cannot
|
|
259
|
+
* verify" with an empty path for that case named nothing the user could go
|
|
260
|
+
* look at.
|
|
261
|
+
*/
|
|
262
|
+
unresolvedPathHint: (env = process.env, home = homedir()) => join(asideHomeDir(env, home), "u"),
|
|
263
|
+
},
|
|
152
264
|
};
|
|
153
265
|
|
|
154
266
|
export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
|
|
@@ -19,7 +19,12 @@ import {
|
|
|
19
19
|
semanticProtectedContributionFingerprint,
|
|
20
20
|
validRefreshablePaths,
|
|
21
21
|
} from "./ownership-policy";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
INTEGRATION_CLIENTS,
|
|
24
|
+
resolveIntegrationPaths,
|
|
25
|
+
unresolvedPathHintFor,
|
|
26
|
+
type IntegrationClientId,
|
|
27
|
+
} from "./registry";
|
|
23
28
|
import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
|
|
24
29
|
|
|
25
30
|
export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe";
|
|
@@ -238,6 +243,42 @@ export function classifyIntegration(input: {
|
|
|
238
243
|
return { state: "unsafe", reason: "blocked-container" };
|
|
239
244
|
}
|
|
240
245
|
if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" };
|
|
246
|
+
|
|
247
|
+
/*
|
|
248
|
+
* Fragments the desired contribution carries beyond the paths this record names. Both
|
|
249
|
+
* states appear whenever a client gains a second owned block:
|
|
250
|
+
*
|
|
251
|
+
* - occupied by a value we did not write -> refuse. A refresh merges the WHOLE
|
|
252
|
+
* contribution, so without this check applying would replace a block the user wrote
|
|
253
|
+
* themselves and report success.
|
|
254
|
+
* - empty -> our own block is missing, because the record predates it. Report drift so
|
|
255
|
+
* a refresh adds it. Without this the file reads `current` forever and the second
|
|
256
|
+
* block never arrives, which is exactly what an older installation hits on upgrade.
|
|
257
|
+
*
|
|
258
|
+
* A byte-identical value is ours in substance: adopt it instead of dead-ending a
|
|
259
|
+
* hand-merged config on a conflict the user can only resolve by deleting our own block.
|
|
260
|
+
*/
|
|
261
|
+
const recordedPaths = new Set((input.record?.fragmentPaths ?? []).map(path => path.join("\u0000")));
|
|
262
|
+
let addedPathMissing = false;
|
|
263
|
+
for (const fragment of input.contribution.fragments) {
|
|
264
|
+
if (recordedPaths.has(fragment.path.join("\u0000"))) continue;
|
|
265
|
+
const observed = readPath(input.parsed, fragment.path);
|
|
266
|
+
if (observed === undefined) {
|
|
267
|
+
addedPathMissing = true;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const one = (value: unknown): string => fingerprint(canonicalContribution({
|
|
271
|
+
clientId: (input.clientId ?? input.record?.clientId) as IntegrationClientId,
|
|
272
|
+
fragments: [{ path: fragment.path, value }],
|
|
273
|
+
}));
|
|
274
|
+
if (one(observed) !== one(fragment.value)) return { state: "conflict", reason: "unowned-key" };
|
|
275
|
+
}
|
|
276
|
+
/*
|
|
277
|
+
* No record: whatever occupies our paths is not ours to touch. A byte-identical value
|
|
278
|
+
* would be ours in substance, but `stale` without a record is not actionable — the writer
|
|
279
|
+
* reads `createdContainers` off the record to decide what it may prune, so adopting a
|
|
280
|
+
* hand-merged block needs an apply path that creates one first. Refuse, exactly as before.
|
|
281
|
+
*/
|
|
241
282
|
if (!input.record) return { state: "conflict", reason: "unowned-key" };
|
|
242
283
|
/*
|
|
243
284
|
* A record proves ownership of ONE file. Change HOME, XDG_CONFIG_HOME,
|
|
@@ -286,6 +327,12 @@ export function classifyIntegration(input: {
|
|
|
286
327
|
}
|
|
287
328
|
return { state: "stale" };
|
|
288
329
|
}
|
|
330
|
+
/*
|
|
331
|
+
* Checked after everything else that could refuse: an owned fragment that no longer
|
|
332
|
+
* matches, or a sibling edit in a format that cannot be rewritten safely, still wins.
|
|
333
|
+
* What is left is a block we own on paper and are merely missing on disk.
|
|
334
|
+
*/
|
|
335
|
+
if (addedPathMissing) return { state: "stale" };
|
|
289
336
|
const desiredFingerprint = typeof input.record.semanticBlockFingerprint === "string"
|
|
290
337
|
? fingerprint(semanticContribution(input.contribution))
|
|
291
338
|
: fingerprint(canonicalContribution(input.contribution));
|
|
@@ -382,15 +429,30 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
|
|
|
382
429
|
let configPath: string;
|
|
383
430
|
let installed: boolean;
|
|
384
431
|
try {
|
|
385
|
-
|
|
386
|
-
|
|
432
|
+
// One resolution for both, so a client whose paths come from mutable state
|
|
433
|
+
// cannot report one account's install beside another account's config path.
|
|
434
|
+
const paths = resolveIntegrationPaths(input.clientId, input.env, input.home);
|
|
435
|
+
configPath = paths.configPath;
|
|
436
|
+
installed = io.statKind(paths.detectDir) === "dir";
|
|
387
437
|
} catch (error) {
|
|
388
438
|
if (!(error instanceof ClientPathError)) throw error;
|
|
439
|
+
/*
|
|
440
|
+
* Two different situations reach here and they are not the same answer.
|
|
441
|
+
*
|
|
442
|
+
* A relative `OPENCLAW_CONFIG_PATH` is a misconfiguration: there is nothing
|
|
443
|
+
* to name, and "cannot verify" is correct. Aside's absent account manifest
|
|
444
|
+
* is the ORDINARY state of an Aside that has been installed and never
|
|
445
|
+
* signed into, and answering that with a red danger badge and an empty path
|
|
446
|
+
* told the user their config was suspect when in fact there is no account
|
|
447
|
+
* yet. A client that can name where its config would go gets `installed:
|
|
448
|
+
* false` and that location, which reads as "not installed" in the UI.
|
|
449
|
+
*/
|
|
450
|
+
const hint = unresolvedPathHintFor(input.clientId, input.env, input.home);
|
|
389
451
|
return {
|
|
390
452
|
clientId: input.clientId,
|
|
391
|
-
state: "unsafe",
|
|
453
|
+
state: hint ? "absent" : "unsafe",
|
|
392
454
|
installed: false,
|
|
393
|
-
configPath:
|
|
455
|
+
configPath: hint,
|
|
394
456
|
reason: "unresolvable-path",
|
|
395
457
|
...retention,
|
|
396
458
|
};
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
semanticProtectedContributionFingerprint,
|
|
29
29
|
} from "./ownership-policy";
|
|
30
30
|
import { createdContainerPaths, mergeContribution, removeFragments } from "./merge";
|
|
31
|
-
import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry";
|
|
31
|
+
import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
|
|
32
32
|
import { classifyIntegration, exportContextOf } from "./state";
|
|
33
33
|
import type { IntegrationState } from "./state";
|
|
34
34
|
import { serializeDocument, UnserializableValueError } from "./serialize";
|
|
@@ -210,8 +210,20 @@ function preflight(input: IntegrationWriteInput) {
|
|
|
210
210
|
* whole Integrations page because one client is misconfigured.
|
|
211
211
|
*/
|
|
212
212
|
let configPath: string;
|
|
213
|
+
let detectDir: string;
|
|
213
214
|
try {
|
|
214
|
-
|
|
215
|
+
/*
|
|
216
|
+
* Resolve the PAIR, never one half.
|
|
217
|
+
*
|
|
218
|
+
* The coordinated path hands us a frozen pair, but applyIntegration,
|
|
219
|
+
* refreshIntegration and disableIntegration are public and may be called
|
|
220
|
+
* without one. Resolving configPath here and detectDir separately later let
|
|
221
|
+
* an Aside account switch land between the two, so a direct apply could
|
|
222
|
+
* verify account 1 was installed and then write account 0's catalog.
|
|
223
|
+
*/
|
|
224
|
+
const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
|
|
225
|
+
configPath = resolved.configPath;
|
|
226
|
+
detectDir = resolved.detectDir;
|
|
215
227
|
} catch (error) {
|
|
216
228
|
if (!(error instanceof ClientPathError)) throw error;
|
|
217
229
|
return { failed: refuse(clientId, "unsafe", "unsafe", error.message) } as const;
|
|
@@ -248,15 +260,34 @@ function preflight(input: IntegrationWriteInput) {
|
|
|
248
260
|
const classified = classifyIntegration({
|
|
249
261
|
fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
|
|
250
262
|
});
|
|
251
|
-
return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } as const;
|
|
263
|
+
return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
|
|
252
264
|
}
|
|
253
265
|
|
|
254
|
-
|
|
266
|
+
/**
|
|
267
|
+
* How a conflicted document is treated.
|
|
268
|
+
*
|
|
269
|
+
* `refuse` is the default and the only behavior that existed: a conflict means
|
|
270
|
+
* something we did not write occupies our paths, or our own block was edited,
|
|
271
|
+
* and guessing which one the user meant to keep is how a toggle deletes work.
|
|
272
|
+
*
|
|
273
|
+
* `overwrite` is the explicit escape hatch. It is never reached by a plain
|
|
274
|
+
* apply -- the caller has to ask for it by name -- because the whole value of
|
|
275
|
+
* the refusal is that it cannot be triggered by accident.
|
|
276
|
+
*/
|
|
277
|
+
type ConflictPolicy = "refuse" | "overwrite";
|
|
278
|
+
|
|
279
|
+
function applyOrRefreshIntegration(
|
|
280
|
+
input: IntegrationWriteInput,
|
|
281
|
+
allowAbsent: boolean,
|
|
282
|
+
conflictPolicy: ConflictPolicy = "refuse",
|
|
283
|
+
): WriteOutcome {
|
|
255
284
|
const pre = preflight(input);
|
|
256
285
|
if (pre.failed) return pre.failed;
|
|
257
|
-
const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre;
|
|
286
|
+
const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
|
|
258
287
|
|
|
259
|
-
|
|
288
|
+
// The detect directory preflight already resolved, so it cannot name a
|
|
289
|
+
// different account than the config path this operation is about to write.
|
|
290
|
+
if (io.statKind(detectDir) !== "dir") {
|
|
260
291
|
return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
|
|
261
292
|
}
|
|
262
293
|
if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
|
|
@@ -264,10 +295,20 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
|
|
|
264
295
|
`The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
|
|
265
296
|
}
|
|
266
297
|
if (classified.state === "conflict") {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
298
|
+
if (conflictPolicy === "refuse") {
|
|
299
|
+
return refuse(clientId, "conflict", "conflict",
|
|
300
|
+
classified.reason === "foreign-edit"
|
|
301
|
+
? `${configPath} changed after opencodex wrote it`
|
|
302
|
+
: `${configPath} already contains an opencodex block we did not write`);
|
|
303
|
+
}
|
|
304
|
+
/*
|
|
305
|
+
* The caller asked for the overwrite explicitly, so the merge below runs
|
|
306
|
+
* against the document as it stands and our block replaces whatever holds
|
|
307
|
+
* our paths. Everything that makes it recoverable is shared with apply --
|
|
308
|
+
* the snapshot, the atomic write, the compare-before-commit recheck and the
|
|
309
|
+
* journal row all come from the same commit() call -- which is why this is a
|
|
310
|
+
* policy flag on one code path rather than a second implementation.
|
|
311
|
+
*/
|
|
271
312
|
}
|
|
272
313
|
/*
|
|
273
314
|
* `unsafe` from the classifier means the document is not one we may write
|
|
@@ -313,7 +354,22 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
|
|
|
313
354
|
*/
|
|
314
355
|
const base = classified.state === "stale" && record
|
|
315
356
|
? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
|
|
316
|
-
:
|
|
357
|
+
: classified.state === "conflict" && record
|
|
358
|
+
/*
|
|
359
|
+
* A forced overwrite of a `foreign-edit` conflict drops what the previous
|
|
360
|
+
* record owned for the same reason a stale refresh does: the replacement
|
|
361
|
+
* record covers the paths we are about to write, so a path the old record
|
|
362
|
+
* owned and the new one does not would be stranded forever, unremovable by
|
|
363
|
+
* any later disable.
|
|
364
|
+
*
|
|
365
|
+
* With NO record -- an `unowned-key` conflict -- there is nothing to drop and
|
|
366
|
+
* the merge runs against the user's document directly. That is correct:
|
|
367
|
+
* createdContainerPaths then attributes every container they already had to
|
|
368
|
+
* them, so a later disable removes our leaves and leaves their structure
|
|
369
|
+
* standing.
|
|
370
|
+
*/
|
|
371
|
+
? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
|
|
372
|
+
: parsed;
|
|
317
373
|
// Computed against the document as it stands BEFORE the merge: afterwards
|
|
318
374
|
// every container exists and "did we create this?" is unanswerable.
|
|
319
375
|
const created = createdContainerPaths(base, contribution);
|
|
@@ -361,7 +417,16 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
|
|
|
361
417
|
const snapshot = store.captureSnapshot(clientId, opId, before);
|
|
362
418
|
const at = new Date(io.now()).toISOString();
|
|
363
419
|
const entry: JournalEntry = {
|
|
364
|
-
|
|
420
|
+
/*
|
|
421
|
+
* `overwrite` is its own kind rather than reusing `apply`. The rollback list
|
|
422
|
+
* is the one place a user goes after a mistake, and "applied" is a lie about
|
|
423
|
+
* an operation that replaced a block somebody else wrote.
|
|
424
|
+
*/
|
|
425
|
+
opId, clientId,
|
|
426
|
+
kind: classified.state === "conflict"
|
|
427
|
+
? "overwrite"
|
|
428
|
+
: classified.state === "stale" ? "refresh" : "apply",
|
|
429
|
+
at, configPath,
|
|
365
430
|
snapshot, resultFingerprint: fingerprint(text), resultAbsent: false, priorRecord: record,
|
|
366
431
|
};
|
|
367
432
|
const refreshablePaths = refreshablePathsOf(contribution);
|
|
@@ -392,6 +457,23 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
|
392
457
|
return applyOrRefreshIntegration(input, true);
|
|
393
458
|
}
|
|
394
459
|
|
|
460
|
+
/**
|
|
461
|
+
* Apply over a conflicted config, replacing whatever holds our paths.
|
|
462
|
+
*
|
|
463
|
+
* Separate from `applyIntegration` and never a flag on it: a caller has to name
|
|
464
|
+
* this function to get the behavior, so no existing call site can acquire it by
|
|
465
|
+
* passing a default through.
|
|
466
|
+
*
|
|
467
|
+
* What it does NOT relax. `unsafe` still refuses -- a blocked container means the
|
|
468
|
+
* merge would replace a value it cannot reason about, and a snapshot is not a
|
|
469
|
+
* licence for that. `not_installed` and `non_loopback` still refuse; neither is a
|
|
470
|
+
* conflict. And a non-conflict state behaves exactly as apply does, so calling
|
|
471
|
+
* this on a clean file is not a way to skip any other check.
|
|
472
|
+
*/
|
|
473
|
+
export function overwriteIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
474
|
+
return applyOrRefreshIntegration(input, true, "overwrite");
|
|
475
|
+
}
|
|
476
|
+
|
|
395
477
|
/** Refresh an owned stale block, but never create or reconnect an absent one. */
|
|
396
478
|
export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
|
|
397
479
|
return applyOrRefreshIntegration(input, false);
|
|
@@ -624,10 +706,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
|
|
|
624
706
|
const store = input.store ?? createIntegrationStateStore();
|
|
625
707
|
const io = input.io ?? defaultIntegrationIO(store);
|
|
626
708
|
const spec = INTEGRATION_CLIENTS[input.clientId];
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
709
|
+
/*
|
|
710
|
+
* One resolution for both paths. Aside derives them from the account id in
|
|
711
|
+
* its manifest, so two independent calls could verify one account's install
|
|
712
|
+
* and then write another account's catalog if a switch landed between them.
|
|
713
|
+
*/
|
|
714
|
+
const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home);
|
|
631
715
|
return { ...input, env, home, store, io, resolvedPaths };
|
|
632
716
|
}
|
|
633
717
|
|
|
@@ -682,6 +766,13 @@ export function refreshIntegrationCoordinated(
|
|
|
682
766
|
return coordinatedWrite(input, refreshIntegration, options);
|
|
683
767
|
}
|
|
684
768
|
|
|
769
|
+
export function overwriteIntegrationCoordinated(
|
|
770
|
+
input: IntegrationWriteInput,
|
|
771
|
+
options?: CoordinatedIntegrationOptions,
|
|
772
|
+
): Promise<WriteOutcome> {
|
|
773
|
+
return coordinatedWrite(input, overwriteIntegration, options);
|
|
774
|
+
}
|
|
775
|
+
|
|
685
776
|
export function disableIntegrationCoordinated(
|
|
686
777
|
input: IntegrationWriteInput,
|
|
687
778
|
options?: CoordinatedIntegrationOptions,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface KillableSubprocess {
|
|
2
|
+
exited: Promise<number>;
|
|
3
|
+
kill(): unknown;
|
|
4
|
+
unref?(): unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface BoundedSubprocessExit {
|
|
8
|
+
exitCode: number | null;
|
|
9
|
+
timedOut: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Kill at the deadline and abandon immediately; late exit/rejection remains observed. */
|
|
13
|
+
export function waitForSubprocessExit(
|
|
14
|
+
proc: KillableSubprocess,
|
|
15
|
+
timeoutMs: number,
|
|
16
|
+
): Promise<BoundedSubprocessExit> {
|
|
17
|
+
return new Promise(resolve => {
|
|
18
|
+
let settled = false;
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
const finish = (result: BoundedSubprocessExit): void => {
|
|
21
|
+
if (settled) return;
|
|
22
|
+
settled = true;
|
|
23
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
24
|
+
resolve(result);
|
|
25
|
+
};
|
|
26
|
+
timer = setTimeout(() => {
|
|
27
|
+
try { proc.kill(); } catch { /* already exited */ }
|
|
28
|
+
try { proc.unref?.(); } catch { /* abandonment is still authoritative */ }
|
|
29
|
+
finish({ exitCode: null, timedOut: true });
|
|
30
|
+
}, Math.max(1, timeoutMs));
|
|
31
|
+
void proc.exited.then(
|
|
32
|
+
exitCode => finish({ exitCode, timedOut: false }),
|
|
33
|
+
() => finish({ exitCode: null, timedOut: false }),
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -29,6 +29,24 @@ export interface GracefulStopIo {
|
|
|
29
29
|
waitExit?: (pid: number, timeoutMs: number) => boolean;
|
|
30
30
|
env?: Record<string, string | undefined>;
|
|
31
31
|
exitTimeoutMs?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Nonce of the pending-teardown receipt this caller claimed.
|
|
34
|
+
*
|
|
35
|
+
* `ocx stop` sets it because it restores shared client config itself, only after
|
|
36
|
+
* proving a stopped Task Scheduler did not respawn the proxy (#3008). The nonce is what
|
|
37
|
+
* makes the deferral an owned obligation rather than a flag anyone can set: the proxy
|
|
38
|
+
* honours it only when it names the receipt actually on disk. Direct callers omit it
|
|
39
|
+
* and keep the self-contained behaviour.
|
|
40
|
+
*/
|
|
41
|
+
deferSharedTeardownNonce?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Endpoint the caller already resolved for this pid.
|
|
44
|
+
*
|
|
45
|
+
* `ocx stop` records this same snapshot in its pending-teardown receipt. Re-reading the
|
|
46
|
+
* runtime file here could pick up a different one, which would make the receipt name an
|
|
47
|
+
* endpoint the stop never contacted — and recovery probes exactly that endpoint.
|
|
48
|
+
*/
|
|
49
|
+
runtimeEndpoint?: { hostname: string; port: number };
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
/**
|
|
@@ -67,7 +85,7 @@ export class ProxyOwnershipRefusedError extends Error {}
|
|
|
67
85
|
*/
|
|
68
86
|
export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}): Promise<GracefulStopResult> {
|
|
69
87
|
const readRuntime = io.readRuntime ?? readRuntimePort;
|
|
70
|
-
const runtime = readRuntime(pid);
|
|
88
|
+
const runtime = io.runtimeEndpoint ?? readRuntime(pid);
|
|
71
89
|
if (!runtime?.port) return false;
|
|
72
90
|
const env = io.env ?? process.env;
|
|
73
91
|
const headers: Record<string, string> = {};
|
|
@@ -75,7 +93,14 @@ export async function stopProxyGracefully(pid: number, io: GracefulStopIo = {}):
|
|
|
75
93
|
if (token) headers["x-opencodex-api-key"] = token;
|
|
76
94
|
const fetchFn = io.fetchFn ?? fetch;
|
|
77
95
|
try {
|
|
78
|
-
|
|
96
|
+
// `ocx stop` asks the proxy NOT to restore shared client config: it does that itself,
|
|
97
|
+
// after verifying a stopped Task Scheduler did not respawn the proxy (#3008). Letting
|
|
98
|
+
// the child do it means a survivor found seconds later has already lost its config.
|
|
99
|
+
const stopUrl = `http://${gracefulStopHost(runtime.hostname)}:${runtime.port}/api/stop`
|
|
100
|
+
+ (io.deferSharedTeardownNonce
|
|
101
|
+
? `?deferSharedTeardown=1&teardownNonce=${encodeURIComponent(io.deferSharedTeardownNonce)}`
|
|
102
|
+
: "");
|
|
103
|
+
const res = await fetchFn(stopUrl, {
|
|
79
104
|
method: "POST",
|
|
80
105
|
headers,
|
|
81
106
|
// Hung proxies with many CLOSE_WAIT clients can be slow to accept; give them
|
|
@@ -107,10 +132,10 @@ function drainDeadlineMs(): number {
|
|
|
107
132
|
}
|
|
108
133
|
|
|
109
134
|
/** Graceful-first stop: management-API drain, then the platform kill ladder. */
|
|
110
|
-
export async function stopProxy(pid: number): Promise<void> {
|
|
135
|
+
export async function stopProxy(pid: number, io: GracefulStopIo = {}): Promise<void> {
|
|
111
136
|
if (!isProcessAlive(pid)) return;
|
|
112
|
-
const runtime = readRuntimePort(pid);
|
|
113
|
-
const graceful = await stopProxyGracefully(pid);
|
|
137
|
+
const runtime = io.runtimeEndpoint ?? readRuntimePort(pid);
|
|
138
|
+
const graceful = await stopProxyGracefully(pid, io);
|
|
114
139
|
if (graceful === "refused") {
|
|
115
140
|
// The proxy refused on purpose (foreign service owns it). Forcing would strip shared
|
|
116
141
|
// config while that service keeps the proxy alive.
|
|
@@ -9,6 +9,7 @@ import { reconcileProviderFetchWarnings } from "../codex/catalog/provider-fetch"
|
|
|
9
9
|
import { reconcileModelCacheGeneration } from "../codex/model-cache";
|
|
10
10
|
import { reconcilePoolRotationState } from "../codex/pool-rotation";
|
|
11
11
|
import { reconcileCodexQuotaAccounts } from "../codex/quota";
|
|
12
|
+
import { reconcileQuotaRecovery, sweepExpiredQuotaRecovery } from "../codex/quota-401-recovery";
|
|
12
13
|
import {
|
|
13
14
|
listLiveCodexAccountIds,
|
|
14
15
|
reconcileCodexRoutingHealth,
|
|
@@ -84,6 +85,13 @@ export const STATE_STORE_REGISTRATIONS = [
|
|
|
84
85
|
},
|
|
85
86
|
{ name: "anthropic-routing-health", sweepExpired: sweepExpiredAnthropicRoutingHealth },
|
|
86
87
|
{ name: "xai-refresh-verdicts", sweepExpired: sweepExpiredXaiPermanentFailureVerdicts },
|
|
88
|
+
{
|
|
89
|
+
name: "codex-quota-401-recovery",
|
|
90
|
+
// Only backoff windows and abandoned leases expire. A spent fence is durable: expiring
|
|
91
|
+
// it would grant the same credential lineage a second refresh (#3019).
|
|
92
|
+
sweepExpired: sweepExpiredQuotaRecovery,
|
|
93
|
+
reconcileGeneration: context => reconcileQuotaRecovery(context.codexAccountIds),
|
|
94
|
+
},
|
|
87
95
|
{
|
|
88
96
|
name: "responses-continuation",
|
|
89
97
|
sweepExpired: sweepExpiredResponseStates,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Core and build metadata are unambiguous and stay inline. The prerelease section does not:
|
|
2
|
+
// the semver.org pattern for one identifier is
|
|
3
|
+
// 0 | [1-9]\d* | [0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*
|
|
4
|
+
// whose three alternatives overlap, and wrapping that in `(?:\.…)*` gives a regex engine an
|
|
5
|
+
// exponential number of ways to split the same string. CodeQL flagged it (`js/redos`) and the
|
|
6
|
+
// cost is real, not theoretical: `0.0.0-0.` followed by repetitions of `--.` took **522ms for a
|
|
7
|
+
// single 125-character input** — inside the 128-char ceiling this module already enforced, and
|
|
8
|
+
// inside the 96-char one its only caller uses. A length cap does not fix superlinear blowup; it
|
|
9
|
+
// only decides where the curve is sampled.
|
|
10
|
+
//
|
|
11
|
+
// So the prerelease section is matched with one non-backtracking pass and its identifiers are
|
|
12
|
+
// validated individually. Each identifier is checked by an anchored regex with no repetition of
|
|
13
|
+
// an alternation, which is linear in the identifier's length.
|
|
14
|
+
const STRICT_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
15
|
+
|
|
16
|
+
const NUMERIC_IDENTIFIER_RE = /^(?:0|[1-9]\d*)$/;
|
|
17
|
+
const ALPHANUMERIC_IDENTIFIER_RE = /^[0-9A-Za-z-]+$/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A prerelease identifier is either a numeric identifier with no leading zero, or an
|
|
21
|
+
* alphanumeric one that contains at least one non-digit. Empty identifiers are invalid,
|
|
22
|
+
* which is what rejects a trailing or doubled dot.
|
|
23
|
+
*/
|
|
24
|
+
function isPrereleaseIdentifier(part: string): boolean {
|
|
25
|
+
if (part.length === 0) return false;
|
|
26
|
+
if (NUMERIC_IDENTIFIER_RE.test(part)) return true;
|
|
27
|
+
return ALPHANUMERIC_IDENTIFIER_RE.test(part) && !/^\d+$/.test(part);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StrictSemver {
|
|
31
|
+
readonly raw: string;
|
|
32
|
+
readonly core: readonly [bigint, bigint, bigint];
|
|
33
|
+
readonly prerelease: readonly (bigint | string)[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parseStrictSemver(value: unknown, maxLength = 128): StrictSemver | null {
|
|
37
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maxLength) return null;
|
|
38
|
+
const match = STRICT_SEMVER_RE.exec(value);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const prereleaseParts = match[4] === undefined ? [] : match[4].split(".");
|
|
41
|
+
if (!prereleaseParts.every(isPrereleaseIdentifier)) return null;
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
raw: value,
|
|
44
|
+
core: Object.freeze([BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)]) as readonly [bigint, bigint, bigint],
|
|
45
|
+
prerelease: Object.freeze(prereleaseParts.map(part => /^\d+$/.test(part) ? BigInt(part) : part)),
|
|
46
|
+
});
|
|
47
|
+
}
|