@bitkyc08/opencodex 2.36.0 → 2.38.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/bin/ocx.mjs +69 -10
- package/gui/dist/assets/index-C14iCj_Q.js +112 -0
- package/gui/dist/assets/index-D7PIz7_g.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/aside.svg +3 -0
- package/gui/dist/provider-icons/deepseek-harness.svg +3 -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/prime-agent.svg +21 -0
- package/gui/dist/provider-icons/zcode.svg +219 -0
- package/package.json +1 -1
- package/src/adapters/cursor/protobuf-request.ts +4 -1
- package/src/adapters/cursor/tool-definitions.ts +36 -4
- package/src/adapters/kiro-constants.ts +36 -2
- package/src/adapters/kiro.ts +13 -2
- package/src/cli/capabilities.ts +14 -0
- package/src/cli/claude.ts +12 -0
- package/src/cli/codex-cli-update.ts +96 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/export-command.ts +18 -17
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +3 -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/clients/config-export.ts +293 -28
- package/src/codex/account-store.ts +10 -4
- package/src/codex/autostart-health.ts +3 -3
- package/src/codex/catalog/provider-fetch.ts +20 -1
- 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/main-account.ts +2 -0
- package/src/codex/model-entitlements.ts +489 -30
- package/src/codex/native-profile-manager.ts +4 -0
- package/src/codex/reset-credit-operation-ledger.ts +1411 -0
- package/src/codex/reset-credit-recovery.ts +20 -2
- package/src/codex/shim.ts +204 -18
- package/src/codex/user-identity.ts +2 -1
- package/src/config/paths.ts +18 -3
- package/src/config.ts +23 -0
- package/src/generated/compatibility-version.json +84 -48
- package/src/integrations/registry.ts +112 -0
- package/src/integrations/state.ts +67 -5
- package/src/integrations/writer.ts +25 -9
- package/src/lib/bounded-subprocess.ts +36 -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/responses/spill-store.ts +334 -29
- package/src/responses/state.ts +488 -7
- package/src/server/index.ts +4 -3
- package/src/server/lifecycle.ts +5 -1
- 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 +7 -2
- package/src/server/startup-action-control.ts +3 -2
- package/src/service.ts +594 -33
- 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 +3 -2
- package/src/update/job.ts +10 -11
- package/gui/dist/assets/index-Cy7Z_pl0.css +0 -1
- package/gui/dist/assets/index-DO8liQVL.js +0 -112
|
@@ -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,17 @@ 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
|
function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: boolean): WriteOutcome {
|
|
255
267
|
const pre = preflight(input);
|
|
256
268
|
if (pre.failed) return pre.failed;
|
|
257
|
-
const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre;
|
|
269
|
+
const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
|
|
258
270
|
|
|
259
|
-
|
|
271
|
+
// The detect directory preflight already resolved, so it cannot name a
|
|
272
|
+
// different account than the config path this operation is about to write.
|
|
273
|
+
if (io.statKind(detectDir) !== "dir") {
|
|
260
274
|
return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
|
|
261
275
|
}
|
|
262
276
|
if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) {
|
|
@@ -624,10 +638,12 @@ function freezeIntegrationInput(input: IntegrationWriteInput): FrozenIntegration
|
|
|
624
638
|
const store = input.store ?? createIntegrationStateStore();
|
|
625
639
|
const io = input.io ?? defaultIntegrationIO(store);
|
|
626
640
|
const spec = INTEGRATION_CLIENTS[input.clientId];
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
641
|
+
/*
|
|
642
|
+
* One resolution for both paths. Aside derives them from the account id in
|
|
643
|
+
* its manifest, so two independent calls could verify one account's install
|
|
644
|
+
* and then write another account's catalog if a switch landed between them.
|
|
645
|
+
*/
|
|
646
|
+
const resolvedPaths = resolveIntegrationPaths(input.clientId, env, home);
|
|
631
647
|
return { ...input, env, home, store, io, resolvedPaths };
|
|
632
648
|
}
|
|
633
649
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -2,6 +2,7 @@ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
|
|
4
4
|
import { dlopen, ptr, type Pointer } from "bun:ffi";
|
|
5
|
+
import { isTestHomeGuardArmed } from "./test-home-guard";
|
|
5
6
|
|
|
6
7
|
type ElevationSpawn = (
|
|
7
8
|
command: string,
|
|
@@ -530,6 +531,19 @@ export function startPowerShellCommand(commandScript: string): WindowsElevationE
|
|
|
530
531
|
};
|
|
531
532
|
}
|
|
532
533
|
|
|
534
|
+
// HOME isolation cannot contain UAC children or other machine-global effects. Keep the
|
|
535
|
+
// final process boundary closed while the real launcher is installed; explicitly injected
|
|
536
|
+
// launchers remain available to tests that exercise the elevation protocol in memory.
|
|
537
|
+
if (isTestHomeGuardArmed() && elevationSpawn === spawn) {
|
|
538
|
+
return {
|
|
539
|
+
launcherPid: null,
|
|
540
|
+
completion: Promise.reject(new WindowsElevationError(
|
|
541
|
+
"launch-failed",
|
|
542
|
+
"Refusing to launch a live Windows elevation process from an armed test process; inject the elevation launcher instead.",
|
|
543
|
+
)),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
533
547
|
let child: ChildProcess;
|
|
534
548
|
try {
|
|
535
549
|
child = elevationSpawn(
|
|
@@ -638,8 +652,16 @@ export function runWindowsElevated(file: string, args: string[]): Promise<number
|
|
|
638
652
|
export function runWindowsElevatedScheduledTaskRegistration(
|
|
639
653
|
taskName: string,
|
|
640
654
|
xml: string,
|
|
655
|
+
replace = false,
|
|
656
|
+
expectedExistingXml?: string,
|
|
641
657
|
): Promise<number> {
|
|
658
|
+
if (replace && !expectedExistingXml?.trim()) {
|
|
659
|
+
throw new Error("Elevated Task Scheduler replacement requires a captured existing definition.");
|
|
660
|
+
}
|
|
642
661
|
const xmlBase64 = Buffer.from(xml, "utf16le").toString("base64");
|
|
662
|
+
const expectedExistingBase64 = expectedExistingXml === undefined
|
|
663
|
+
? null
|
|
664
|
+
: Buffer.from(expectedExistingXml, "utf16le").toString("base64");
|
|
643
665
|
const powerShellPath = windowsPowerShell();
|
|
644
666
|
const powerShellDirectory = powerShellPath.replace(/[\\/][^\\/]+$/, "");
|
|
645
667
|
const scheduledTasksModule = `${powerShellDirectory}\\Modules\\ScheduledTasks\\ScheduledTasks.psd1`;
|
|
@@ -650,7 +672,16 @@ export function runWindowsElevatedScheduledTaskRegistration(
|
|
|
650
672
|
`$module = Microsoft.PowerShell.Core\\Import-Module -Name ${psSingleQuote(scheduledTasksModule)} -PassThru -Force -ErrorAction Stop`,
|
|
651
673
|
"$registerTask = $module.ExportedCommands['Register-ScheduledTask']",
|
|
652
674
|
"if ($null -eq $registerTask) { throw 'Trusted ScheduledTasks module does not export Register-ScheduledTask.' }",
|
|
653
|
-
|
|
675
|
+
...(replace ? [
|
|
676
|
+
`$expectedBase64 = ${psSingleQuote(expectedExistingBase64!)}`,
|
|
677
|
+
"$expectedXml = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($expectedBase64))",
|
|
678
|
+
`$schtasks = ${psSingleQuote(resolveTrustedWindowsSchtasksExe())}`,
|
|
679
|
+
"$currentXml = & $schtasks /query /tn $taskName /xml 2>$null | Out-String",
|
|
680
|
+
"if ($LASTEXITCODE -ne 0) { throw 'Task Scheduler replacement precondition could not be read.' }",
|
|
681
|
+
"function Normalize-OcxTaskXml([string]$value) { return (($value.TrimStart([char]0xFEFF) -replace \"`r`n?\", \"`n\").Trim()) }",
|
|
682
|
+
"if ((Normalize-OcxTaskXml $currentXml) -cne (Normalize-OcxTaskXml $expectedXml)) { throw 'Task Scheduler replacement precondition changed.' }",
|
|
683
|
+
] : []),
|
|
684
|
+
`& $registerTask -TaskName $taskName -Xml $xml${replace ? " -Force" : ""} -ErrorAction Stop | Out-Null`,
|
|
654
685
|
].join("; ");
|
|
655
686
|
const encodedCommand = Buffer.from(inner, "utf16le").toString("base64");
|
|
656
687
|
const script = [
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import { existsSync, statSync } from "node:fs";
|
|
33
33
|
import { env, platform } from "node:process";
|
|
34
|
+
import { waitForSubprocessExit } from "./bounded-subprocess";
|
|
34
35
|
import { resolveTrustedWindowsIcaclsExe } from "./windows-elevation";
|
|
35
36
|
import {
|
|
36
37
|
cachedCurrentWindowsIdentity,
|
|
@@ -216,6 +217,11 @@ export interface HardenResult {
|
|
|
216
217
|
|
|
217
218
|
export interface HardenOptions {
|
|
218
219
|
required: boolean;
|
|
220
|
+
/**
|
|
221
|
+
* Explicit total budget for this harden call. Shutdown recovery uses a reduced
|
|
222
|
+
* caller-owned slice instead of opening the normal 30-second window.
|
|
223
|
+
*/
|
|
224
|
+
deadlineMs?: number;
|
|
219
225
|
/**
|
|
220
226
|
* Optional timeout-memo key distinct from `targetPath` (issue #612).
|
|
221
227
|
* Atomic writers mint a fresh `.tmp` path per write; keying the timeout cache by the
|
|
@@ -257,7 +263,11 @@ const HARDEN_DEADLINE_MIN_MS = 1_000;
|
|
|
257
263
|
const HARDEN_DEADLINE_MAX_MS = 60_000;
|
|
258
264
|
|
|
259
265
|
/** Resolve the total harden budget once per call (env mutation cannot change it midway). */
|
|
260
|
-
function resolveHardenDeadlineMs(): number {
|
|
266
|
+
function resolveHardenDeadlineMs(overrideMs?: number): number {
|
|
267
|
+
if (overrideMs !== undefined) {
|
|
268
|
+
if (!Number.isSafeInteger(overrideMs) || overrideMs <= 0) return 1;
|
|
269
|
+
return Math.min(HARDEN_DEADLINE_MAX_MS, overrideMs);
|
|
270
|
+
}
|
|
261
271
|
const raw = env["OPENCODEX_ACL_TIMEOUT_MS"]?.trim();
|
|
262
272
|
if (!raw) return HARDEN_DEADLINE_DEFAULT_MS;
|
|
263
273
|
const parsed = Number(raw);
|
|
@@ -328,27 +338,16 @@ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
|
|
|
328
338
|
|
|
329
339
|
/**
|
|
330
340
|
* Async icacls runner (#612): yields the event loop while waiting for the child.
|
|
331
|
-
*
|
|
332
|
-
*
|
|
341
|
+
* Async Subprocess has no exitedDueToTimeout, so the shared settlement helper
|
|
342
|
+
* classifies the deadline and abandons a child that does not settle after kill.
|
|
333
343
|
*/
|
|
334
344
|
async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
|
|
335
345
|
const proc = trySpawnIcacls(args);
|
|
336
346
|
if (!proc) return spawnFailedResult();
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
timedOutByUs = true;
|
|
340
|
-
try { proc.kill(); } catch { /* already exited */ }
|
|
341
|
-
}, Math.max(1, timeoutMs));
|
|
342
|
-
let exitCode: number | null = null;
|
|
343
|
-
try {
|
|
344
|
-
exitCode = await proc.exited;
|
|
345
|
-
} finally {
|
|
346
|
-
clearTimeout(timer);
|
|
347
|
-
}
|
|
348
|
-
const stdout = proc.stdout
|
|
347
|
+
const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
|
|
348
|
+
const stdout = !timedOut && proc.stdout
|
|
349
349
|
? await new Response(proc.stdout).text().catch(() => "")
|
|
350
350
|
: "";
|
|
351
|
-
const timedOut = timedOutByUs;
|
|
352
351
|
return {
|
|
353
352
|
success: !timedOut && exitCode === 0,
|
|
354
353
|
exitCode: timedOut ? null : exitCode,
|
|
@@ -357,6 +356,24 @@ async function defaultAsyncIcaclsRunner(args: string[], timeoutMs: number): Prom
|
|
|
357
356
|
};
|
|
358
357
|
}
|
|
359
358
|
|
|
359
|
+
function awaitAsyncIcaclsRunner(args: string[], timeoutMs: number): Promise<IcaclsResult> {
|
|
360
|
+
return new Promise(resolve => {
|
|
361
|
+
let settled = false;
|
|
362
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
+
const finish = (result: IcaclsResult): void => {
|
|
364
|
+
if (settled) return;
|
|
365
|
+
settled = true;
|
|
366
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
367
|
+
resolve(result);
|
|
368
|
+
};
|
|
369
|
+
timer = setTimeout(
|
|
370
|
+
() => finish({ success: false, exitCode: null, timedOut: true, stdout: "" }),
|
|
371
|
+
Math.max(1, timeoutMs),
|
|
372
|
+
);
|
|
373
|
+
void asyncIcaclsRunner(args, timeoutMs).then(finish, () => finish(spawnFailedResult()));
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
360
377
|
let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
|
|
361
378
|
let asyncIcaclsRunner: AsyncIcaclsRunner = defaultAsyncIcaclsRunner;
|
|
362
379
|
let platformOverride: string | null = null;
|
|
@@ -537,12 +554,14 @@ function shouldVerifyExistingAcl(): boolean {
|
|
|
537
554
|
return env["OPENCODEX_ACL_VERIFY_EXISTING"] === "1";
|
|
538
555
|
}
|
|
539
556
|
|
|
540
|
-
function existingAclAlreadyCompliant(targetPath: string, directory: boolean): boolean {
|
|
557
|
+
function existingAclAlreadyCompliant(targetPath: string, directory: boolean, deadline: number): boolean {
|
|
541
558
|
if (!shouldVerifyExistingAcl()) return false;
|
|
542
559
|
const identity = cachedCurrentWindowsIdentity();
|
|
543
560
|
if (!identity) return false;
|
|
544
561
|
try {
|
|
545
|
-
const
|
|
562
|
+
const remaining = deadline - nowFn();
|
|
563
|
+
if (remaining <= 0) return false;
|
|
564
|
+
const result = icaclsRunner([targetPath], remaining);
|
|
546
565
|
return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
|
|
547
566
|
} catch {
|
|
548
567
|
return false;
|
|
@@ -552,12 +571,15 @@ function existingAclAlreadyCompliant(targetPath: string, directory: boolean): bo
|
|
|
552
571
|
async function existingAclAlreadyCompliantAsync(
|
|
553
572
|
targetPath: string,
|
|
554
573
|
directory: boolean,
|
|
574
|
+
deadline: number,
|
|
555
575
|
): Promise<boolean> {
|
|
556
576
|
if (!shouldVerifyExistingAcl()) return false;
|
|
557
577
|
const identity = cachedCurrentWindowsIdentity();
|
|
558
578
|
if (!identity) return false;
|
|
559
579
|
try {
|
|
560
|
-
const
|
|
580
|
+
const remaining = deadline - nowFn();
|
|
581
|
+
if (remaining <= 0) return false;
|
|
582
|
+
const result = await awaitAsyncIcaclsRunner([targetPath], remaining);
|
|
561
583
|
return result.success && existingAclIsCompliant(targetPath, directory, result.stdout, identity.name);
|
|
562
584
|
} catch {
|
|
563
585
|
return false;
|
|
@@ -618,7 +640,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline:
|
|
|
618
640
|
if (remaining <= 0) {
|
|
619
641
|
throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" });
|
|
620
642
|
}
|
|
621
|
-
return
|
|
643
|
+
return awaitAsyncIcaclsRunner(args, remaining);
|
|
622
644
|
};
|
|
623
645
|
const runOrThrow = async (step: string, args: string[]): Promise<void> => {
|
|
624
646
|
const result = await run(step, args);
|
|
@@ -751,7 +773,7 @@ async function describeAclStateAfterTimeoutAsync(targetPath: string, deadline: n
|
|
|
751
773
|
for (const sid of BROAD_SIDS) {
|
|
752
774
|
const remaining = deadline - nowFn();
|
|
753
775
|
if (remaining <= 0) return "ACL state unverified (budget exhausted)";
|
|
754
|
-
const found = await
|
|
776
|
+
const found = await awaitAsyncIcaclsRunner([targetPath, "/findsid", sid], remaining);
|
|
755
777
|
if (!found.success) return "ACL state unverified (probe failed)";
|
|
756
778
|
if (found.stdout.includes(targetPath)) return "broad ACL grants still present";
|
|
757
779
|
}
|
|
@@ -788,7 +810,8 @@ function hardenEntry(
|
|
|
788
810
|
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
|
|
789
811
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
790
812
|
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
791
|
-
|
|
813
|
+
const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
|
|
814
|
+
if (existingAclAlreadyCompliant(targetPath, directory, deadline)) return { ok: true };
|
|
792
815
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
793
816
|
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
|
|
794
817
|
if (timeoutMemoError) {
|
|
@@ -796,7 +819,6 @@ function hardenEntry(
|
|
|
796
819
|
return { ok: false, diagnostics: timeoutMemoError.message };
|
|
797
820
|
}
|
|
798
821
|
|
|
799
|
-
const deadline = nowFn() + resolveHardenDeadlineMs();
|
|
800
822
|
let lastErr: unknown;
|
|
801
823
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
802
824
|
if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains
|
|
@@ -841,7 +863,8 @@ async function hardenEntryAsync(
|
|
|
841
863
|
if (!existsSync(targetPath)) { cache.delete(targetPath); return { ok: true }; }
|
|
842
864
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
843
865
|
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
844
|
-
|
|
866
|
+
const deadline = nowFn() + resolveHardenDeadlineMs(opts.deadlineMs);
|
|
867
|
+
if (await existingAclAlreadyCompliantAsync(targetPath, directory, deadline)) return { ok: true };
|
|
845
868
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
846
869
|
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
|
|
847
870
|
if (timeoutMemoError) {
|
|
@@ -849,7 +872,6 @@ async function hardenEntryAsync(
|
|
|
849
872
|
return { ok: false, diagnostics: timeoutMemoError.message };
|
|
850
873
|
}
|
|
851
874
|
|
|
852
|
-
const deadline = nowFn() + resolveHardenDeadlineMs();
|
|
853
875
|
let lastErr: unknown;
|
|
854
876
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
855
877
|
if (attempt > 0 && deadline - nowFn() <= 0) break;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { chmodSync, lstatSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { resolveEffectiveUserIdentity, resolveEffectiveUserRuntimeRoot } from "../codex/user-identity";
|
|
6
|
+
import { hardenSecretDir, hardenSecretPath } from "./windows-secret-acl";
|
|
7
|
+
|
|
8
|
+
type LockDatabase = Pick<Database, "exec" | "close">;
|
|
9
|
+
|
|
10
|
+
export interface WindowsServiceMutationLockDeps {
|
|
11
|
+
lockPath?: string;
|
|
12
|
+
openDatabase?: (path: string) => LockDatabase;
|
|
13
|
+
hardenDirectory?: (path: string) => void;
|
|
14
|
+
hardenFile?: (path: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class WindowsServiceMutationBusyError extends Error {
|
|
18
|
+
readonly code = "WINDOWS_SERVICE_MUTATION_BUSY";
|
|
19
|
+
|
|
20
|
+
constructor() {
|
|
21
|
+
super("Another OpenCodex Windows service operation is already in progress. Wait for it to finish, then retry.");
|
|
22
|
+
this.name = "WindowsServiceMutationBusyError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class WindowsServiceMutationLockError extends Error {
|
|
27
|
+
readonly code = "WINDOWS_SERVICE_MUTATION_LOCK_FAILED";
|
|
28
|
+
|
|
29
|
+
constructor(operation: "acquire" | "release", cause: unknown) {
|
|
30
|
+
super(`The Windows service mutation lock could not be ${operation === "acquire" ? "acquired" : "released"}.`, { cause });
|
|
31
|
+
this.name = "WindowsServiceMutationLockError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function errorCode(error: unknown): string | undefined {
|
|
36
|
+
return error && typeof error === "object" && "code" in error
|
|
37
|
+
? String((error as { code?: unknown }).code)
|
|
38
|
+
: undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isBusy(error: unknown): boolean {
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return errorCode(error) === "SQLITE_BUSY"
|
|
44
|
+
|| errorCode(error) === "SQLITE_LOCKED"
|
|
45
|
+
|| /database (?:is|table is) locked/i.test(message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stable per-user lock namespace for the fixed `opencodex-proxy` task name.
|
|
50
|
+
*
|
|
51
|
+
* Deliberately outside OPENCODEX_HOME: creating the lock must not make a genuinely fresh
|
|
52
|
+
* config root look pre-existing before the installer records its uninstall ownership. The
|
|
53
|
+
* effective-user runtime root ignores LOCALAPPDATA/USERPROFILE overrides, so two processes
|
|
54
|
+
* running as the same SID cannot split the lock by changing their environment or config home.
|
|
55
|
+
*/
|
|
56
|
+
export function windowsServiceMutationLockPath(): string {
|
|
57
|
+
const identity = resolveEffectiveUserIdentity();
|
|
58
|
+
if (identity.platform !== "win32") {
|
|
59
|
+
throw new Error("The Windows service mutation lock is only available on Windows.");
|
|
60
|
+
}
|
|
61
|
+
return join(resolveEffectiveUserRuntimeRoot(identity), "windows-service-mutation.sqlite");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertRegularPath(path: string, kind: "directory" | "file"): void {
|
|
65
|
+
const entry = lstatSync(path);
|
|
66
|
+
const valid = kind === "directory" ? entry.isDirectory() : entry.isFile();
|
|
67
|
+
if (!valid || entry.isSymbolicLink()) {
|
|
68
|
+
throw new Error(`The Windows service mutation lock ${kind} is not a regular ${kind}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Serialize one complete Windows service mutation across OpenCodex processes.
|
|
74
|
+
*
|
|
75
|
+
* The SQLite write transaction is the lock. It stays held across UAC and async verification,
|
|
76
|
+
* and the OS releases it if the process exits, so no stale lock file needs unsafe reclamation.
|
|
77
|
+
*/
|
|
78
|
+
export async function withWindowsServiceMutationLock<T>(
|
|
79
|
+
operation: () => Promise<T>,
|
|
80
|
+
deps: WindowsServiceMutationLockDeps = {},
|
|
81
|
+
): Promise<T> {
|
|
82
|
+
const lockPath = deps.lockPath ?? windowsServiceMutationLockPath();
|
|
83
|
+
const lockDir = dirname(lockPath);
|
|
84
|
+
let database: LockDatabase | undefined;
|
|
85
|
+
let acquired = false;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
mkdirSync(lockDir, { recursive: true, mode: 0o700 });
|
|
89
|
+
assertRegularPath(lockDir, "directory");
|
|
90
|
+
try { chmodSync(lockDir, 0o700); } catch { /* Windows ACL below is authoritative. */ }
|
|
91
|
+
(deps.hardenDirectory ?? (path => { hardenSecretDir(path, { required: true }); }))(lockDir);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
database = (deps.openDatabase ?? (path => new Database(path, { create: true })))(lockPath);
|
|
95
|
+
assertRegularPath(lockPath, "file");
|
|
96
|
+
try { chmodSync(lockPath, 0o600); } catch { /* Windows ACL below is authoritative. */ }
|
|
97
|
+
(deps.hardenFile ?? (path => { hardenSecretPath(path, { required: true }); }))(lockPath);
|
|
98
|
+
database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
|
|
99
|
+
acquired = true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
try { database?.close(); } catch { /* acquisition already failed */ }
|
|
102
|
+
database = undefined;
|
|
103
|
+
if (isBusy(error)) throw new WindowsServiceMutationBusyError();
|
|
104
|
+
throw new WindowsServiceMutationLockError("acquire", error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let result: T;
|
|
108
|
+
let operationError: unknown;
|
|
109
|
+
try {
|
|
110
|
+
result = await operation();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
operationError = error;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let releaseError: unknown;
|
|
116
|
+
if (acquired) {
|
|
117
|
+
try { database.exec("ROLLBACK"); } catch (error) { releaseError = error; }
|
|
118
|
+
}
|
|
119
|
+
try { database.close(); } catch (error) { releaseError ??= error; }
|
|
120
|
+
acquired = false;
|
|
121
|
+
database = undefined;
|
|
122
|
+
|
|
123
|
+
if (operationError !== undefined) throw operationError;
|
|
124
|
+
if (releaseError !== undefined) throw new WindowsServiceMutationLockError("release", releaseError);
|
|
125
|
+
return result!;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (acquired) {
|
|
128
|
+
try { database?.exec("ROLLBACK"); } catch { /* close still releases the OS lock */ }
|
|
129
|
+
}
|
|
130
|
+
try { database?.close(); } catch { /* preserve the primary error */ }
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|