@bitkyc08/opencodex 2.10.2 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/index-Bk-PN-70.css +1 -0
- package/gui/dist/assets/index-BynIEIV-.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/live-transport.ts +11 -0
- package/src/adapters/cursor/native-exec-fs.ts +9 -6
- package/src/adapters/cursor/native-exec.ts +4 -2
- package/src/adapters/cursor/protobuf-events.ts +176 -4
- package/src/adapters/cursor/request-builder.ts +15 -4
- package/src/adapters/cursor/tool-definitions.ts +118 -2
- package/src/adapters/google.ts +15 -5
- package/src/adapters/openai-chat.ts +24 -2
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +9 -5
- package/src/chat/outbound.ts +4 -3
- package/src/claude/desktop-3p.ts +222 -2
- package/src/claude/outbound.ts +15 -6
- package/src/cli/account-api.ts +4 -0
- package/src/cli/account-extended.ts +112 -0
- package/src/cli/account.ts +23 -6
- package/src/cli/claude-desktop.ts +26 -3
- package/src/cli/config-command.ts +9 -0
- package/src/cli/help.ts +18 -2
- package/src/cli/index.ts +277 -55
- package/src/cli/models.ts +5 -1
- package/src/cli/provider.ts +8 -2
- package/src/cli/ready.ts +301 -0
- package/src/cli/system-restart-client.ts +146 -0
- package/src/cli/tray-proxy.ts +153 -6
- package/src/clients/config-export.ts +12 -19
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-namespaces.ts +49 -3
- package/src/codex/account-priority.ts +83 -0
- package/src/codex/auth-api.ts +83 -0
- package/src/codex/auth-context.ts +5 -2
- package/src/codex/catalog/provider-fetch.ts +11 -0
- package/src/codex/catalog/sync.ts +23 -1
- package/src/codex/codex-write-lock.ts +16 -4
- package/src/codex/desired-state.ts +37 -4
- package/src/codex/history-job.ts +15 -5
- package/src/codex/history-provider.ts +31 -14
- package/src/codex/history-worker.ts +28 -4
- package/src/codex/inject-coordination.ts +13 -1
- package/src/codex/inject.ts +360 -66
- package/src/codex/internal/history-writer.ts +1 -1
- package/src/codex/native-main-lock-file.ts +5 -1
- package/src/codex/native-main-owner.ts +17 -3
- package/src/codex/native-profile-manager.ts +19 -0
- package/src/codex/native-profile-startup.ts +8 -0
- package/src/codex/native-residue.ts +140 -27
- package/src/codex/pool-rotation.ts +74 -4
- package/src/codex/refresh.ts +7 -0
- package/src/codex/routing.ts +177 -36
- package/src/codex/subagent-model-fallback.ts +34 -4
- package/src/codex/sync.ts +61 -0
- package/src/codex/upstream-host-health.ts +329 -31
- package/src/combos/request.ts +2 -0
- package/src/config.ts +221 -2
- package/src/images/loop.ts +1 -1
- package/src/integrations/native/ownership-preflight.ts +39 -2
- package/src/lib/bun-stream-caps.ts +3 -3
- package/src/lib/sse-decoder.ts +41 -0
- package/src/lib/system-restart-contract.ts +73 -0
- package/src/lib/windows-secret-acl.ts +141 -39
- package/src/lib/windows-user-principal.ts +283 -0
- package/src/lib/winsw.ts +18 -2
- package/src/oauth/key-providers.ts +12 -0
- package/src/providers/derive.ts +54 -2
- package/src/providers/free-directory.ts +6 -5
- package/src/providers/model-discovery.ts +9 -3
- package/src/providers/quota.ts +592 -0
- package/src/providers/registry.ts +316 -13
- package/src/responses/parser.ts +26 -10
- package/src/responses/reasoning-replay-cache.ts +1 -0
- package/src/routing/profile-namespace.ts +15 -0
- package/src/routing/profile.ts +2 -1
- package/src/server/auth-cors.ts +44 -13
- package/src/server/chat-completions.ts +0 -4
- package/src/server/claude-messages.ts +73 -15
- package/src/server/github-copilot-responses-repair.ts +338 -0
- package/src/server/index.ts +328 -111
- package/src/server/lifecycle.ts +36 -0
- package/src/server/management/agent-settings-routes.ts +147 -56
- package/src/server/management/config-routes.ts +7 -2
- package/src/server/management/context.ts +4 -0
- package/src/server/management/native-integration-routes.ts +199 -20
- package/src/server/management/provider-routes.ts +41 -0
- package/src/server/management/routing-profile-routes.ts +234 -5
- package/src/server/management/system-restart.ts +12 -10
- package/src/server/management/system-routes.ts +20 -0
- package/src/server/management-auth.ts +51 -3
- package/src/server/ports.ts +41 -1
- package/src/server/proxy-liveness.ts +129 -4
- package/src/server/readiness.ts +99 -0
- package/src/server/relay.ts +113 -97
- package/src/server/request-log.ts +10 -4
- package/src/server/responses/compact.ts +107 -12
- package/src/server/responses/core.ts +220 -39
- package/src/server/responses-item-id-repair.ts +22 -3
- package/src/server/responses-model-rewrite.ts +29 -0
- package/src/server/sse-frame-buffer.ts +292 -0
- package/src/server/sse-payload-rewrite.ts +25 -14
- package/src/server/ws-bridge.ts +27 -22
- package/src/service-manager-probe.ts +520 -10
- package/src/service.ts +134 -2
- package/src/storage/worker-lifecycle.ts +14 -14
- package/src/tray/windows-tray.ps1 +74 -9
- package/src/types.ts +68 -2
- package/src/update/index.ts +12 -0
- package/src/update/job.ts +392 -18
- package/src/update/npm-cache-preflight.d.mts +47 -0
- package/src/update/npm-cache-preflight.mjs +201 -0
- package/src/usage/log.ts +1 -1
- package/src/vision/index.ts +77 -2
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/parse.ts +4 -1
- package/gui/dist/assets/index-BKVqyYqT.js +0 -70
- package/gui/dist/assets/index-Ca_3269W.css +0 -1
|
@@ -31,11 +31,20 @@
|
|
|
31
31
|
|
|
32
32
|
import { existsSync, statSync } from "node:fs";
|
|
33
33
|
import { env, platform } from "node:process";
|
|
34
|
+
import {
|
|
35
|
+
resolveCurrentWindowsPrincipal,
|
|
36
|
+
resolveCurrentWindowsPrincipalAsync,
|
|
37
|
+
setSyntheticWindowsPrincipalForTests,
|
|
38
|
+
} from "./windows-user-principal";
|
|
34
39
|
|
|
35
40
|
const hardenedDirectories = new Map<string, HardenedIdentity>();
|
|
36
41
|
const hardenedPaths = new Map<string, HardenedIdentity>();
|
|
37
|
-
/**
|
|
38
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them.
|
|
44
|
+
* `false` means one explicitly authorized recovery attempt remains; `true` means
|
|
45
|
+
* that attempt was consumed. Ordinary callers never consume it.
|
|
46
|
+
*/
|
|
47
|
+
const timedOutPaths = new Map<string, boolean>();
|
|
39
48
|
|
|
40
49
|
/**
|
|
41
50
|
* The memo value: `object:freshness` for a file a harden was actually attributed
|
|
@@ -212,6 +221,12 @@ export interface HardenOptions {
|
|
|
212
221
|
* Must NOT be a parent directory — directory ACLs are not authoritative for new files.
|
|
213
222
|
*/
|
|
214
223
|
timeoutMemoKey?: string;
|
|
224
|
+
/**
|
|
225
|
+
* Consume the one recovery attempt for a previously timed-out memo key.
|
|
226
|
+
* Only a caller that owns its own single-flight and bounded retry policy should
|
|
227
|
+
* set this. It never clears or bypasses an already-consumed timeout memo.
|
|
228
|
+
*/
|
|
229
|
+
retryTimedOutOnce?: boolean;
|
|
215
230
|
}
|
|
216
231
|
|
|
217
232
|
/**
|
|
@@ -219,9 +234,23 @@ export interface HardenOptions {
|
|
|
219
234
|
* timeout retry and the diagnostic verification pass (no per-attempt fresh budget:
|
|
220
235
|
* loadConfig hardens dir+config+auth sequentially, so per-attempt budgets stack
|
|
221
236
|
* into multi-minute startup stalls). Override with OPENCODEX_ACL_TIMEOUT_MS
|
|
222
|
-
* (integer ms, clamped to [1000, 60000]; invalid values fall back to
|
|
237
|
+
* (integer ms, clamped to [1000, 60000]; invalid values fall back to 30000).
|
|
238
|
+
*
|
|
239
|
+
* The default was 5s until #1156. One envelope has to cover the whole sequence —
|
|
240
|
+
* `/grant:r`, `/inheritance:r`, `/remove:g`, plus the conditional `/findsid`
|
|
241
|
+
* verification — and on machines where icacls is slow (Defender real-time scanning,
|
|
242
|
+
* roaming profiles, a domain-controller round trip) 5s ran out mid-sequence. The
|
|
243
|
+
* harden then failed closed, the native-main owner published a permanent
|
|
244
|
+
* `unavailable`, and every native request returned 503 until restart. A slow start
|
|
245
|
+
* is recoverable; that is not.
|
|
246
|
+
*
|
|
247
|
+
* The cost is honest and worth stating: because loadConfig hardens three paths
|
|
248
|
+
* sequentially, the timeout-path worst case at load is ~90s, and the owner path
|
|
249
|
+
* (initial call + one recovery) is ~60.25s. Both require icacls to be
|
|
250
|
+
* pathologically slow on every call; a healthy machine finishes in milliseconds
|
|
251
|
+
* and sees no change. Operators who prefer the old bound can set the env override.
|
|
223
252
|
*/
|
|
224
|
-
const HARDEN_DEADLINE_DEFAULT_MS =
|
|
253
|
+
const HARDEN_DEADLINE_DEFAULT_MS = 30_000;
|
|
225
254
|
const HARDEN_DEADLINE_MIN_MS = 1_000;
|
|
226
255
|
const HARDEN_DEADLINE_MAX_MS = 60_000;
|
|
227
256
|
|
|
@@ -312,9 +341,21 @@ export function setAsyncIcaclsRunnerForTests(runner: AsyncIcaclsRunner | null):
|
|
|
312
341
|
asyncIcaclsRunner = runner ?? defaultAsyncIcaclsRunner;
|
|
313
342
|
}
|
|
314
343
|
|
|
315
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner.
|
|
346
|
+
*
|
|
347
|
+
* Faking win32 on a host without System32 also has to supply a principal, or
|
|
348
|
+
* every forced-branch test would fail on the identity lookup instead of
|
|
349
|
+
* exercising icacls. The synthetic value is registered with the resolver, not
|
|
350
|
+
* chosen here, so a test that injects its own runner still wins.
|
|
351
|
+
*/
|
|
352
|
+
const SYNTHETIC_TEST_PRINCIPAL = "*S-1-5-21-1-2-3-1001";
|
|
353
|
+
|
|
316
354
|
export function setPlatformForTests(value: string | null): void {
|
|
317
355
|
platformOverride = value;
|
|
356
|
+
setSyntheticWindowsPrincipalForTests(
|
|
357
|
+
value === "win32" && platform !== "win32" ? SYNTHETIC_TEST_PRINCIPAL : null,
|
|
358
|
+
);
|
|
318
359
|
}
|
|
319
360
|
|
|
320
361
|
/** Test seam: injectable clock for deadline tests (no real sleeps). */
|
|
@@ -384,17 +425,26 @@ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException
|
|
|
384
425
|
}
|
|
385
426
|
|
|
386
427
|
/**
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
428
|
+
* The ACL principal is the effective token SID and nothing else.
|
|
429
|
+
*
|
|
430
|
+
* There is no name-shaped fallback here, and that absence is the fix for #1149
|
|
431
|
+
* rather than an omission. `USERDOMAIN\USERNAME` has the right shape but is not
|
|
432
|
+
* evidence of the current token's subject, and both variables are writable by
|
|
433
|
+
* the process that launched us. Granting Full Control to a wrong principal and
|
|
434
|
+
* then running `/inheritance:r` is destructive in both directions: another
|
|
435
|
+
* account can be left holding the secret, or the file can be left with no ACE
|
|
436
|
+
* the current user can use. When the SID cannot be resolved we decline.
|
|
437
|
+
*
|
|
438
|
+
* Non-Windows hosts that force this branch through `setPlatformForTests` get
|
|
439
|
+
* their principal from `setSyntheticWindowsPrincipalForTests`, which lives with
|
|
440
|
+
* the resolver so an injected runner can still take precedence over it.
|
|
390
441
|
*/
|
|
391
|
-
function
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
return domain ? `${domain}\\${username}` : username;
|
|
442
|
+
function currentWindowsPrincipal(deadline: number): string {
|
|
443
|
+
return resolveCurrentWindowsPrincipal(deadline - nowFn());
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function currentWindowsPrincipalAsync(deadline: number): Promise<string> {
|
|
447
|
+
return resolveCurrentWindowsPrincipalAsync(deadline - nowFn());
|
|
398
448
|
}
|
|
399
449
|
|
|
400
450
|
/**
|
|
@@ -414,10 +464,7 @@ function grantAce(user: string, directory: boolean): string {
|
|
|
414
464
|
}
|
|
415
465
|
|
|
416
466
|
function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
|
|
417
|
-
const
|
|
418
|
-
if (!user) {
|
|
419
|
-
throw new Error("Cannot determine current Windows user for ACL hardening");
|
|
420
|
-
}
|
|
467
|
+
const principal = currentWindowsPrincipal(deadline);
|
|
421
468
|
|
|
422
469
|
// The deadline is owned by hardenEntry (total budget incl. retry + verification).
|
|
423
470
|
const run = (step: string, args: string[]): IcaclsResult => {
|
|
@@ -434,7 +481,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
|
|
|
434
481
|
|
|
435
482
|
// Step 1: grant current user full control BEFORE any destructive ACL change.
|
|
436
483
|
// If this fails, inheritance is untouched and the writer keeps inherited access.
|
|
437
|
-
runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(
|
|
484
|
+
runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);
|
|
438
485
|
|
|
439
486
|
// Step 2: disable inheritance and remove inherited ACEs. The explicit owner ACE
|
|
440
487
|
// from step 1 survives this transition, so a later failure still leaves cleanup access.
|
|
@@ -463,10 +510,7 @@ function runIcacls(targetPath: string, directory: boolean, deadline: number): vo
|
|
|
463
510
|
|
|
464
511
|
/** Async counterpart of runIcacls — same step order and timeout/error classification (#612). */
|
|
465
512
|
async function runIcaclsAsync(targetPath: string, directory: boolean, deadline: number): Promise<void> {
|
|
466
|
-
const
|
|
467
|
-
if (!user) {
|
|
468
|
-
throw new Error("Cannot determine current Windows user for ACL hardening");
|
|
469
|
-
}
|
|
513
|
+
const principal = await currentWindowsPrincipalAsync(deadline);
|
|
470
514
|
|
|
471
515
|
const run = async (step: string, args: string[]): Promise<IcaclsResult> => {
|
|
472
516
|
const remaining = deadline - nowFn();
|
|
@@ -480,7 +524,7 @@ async function runIcaclsAsync(targetPath: string, directory: boolean, deadline:
|
|
|
480
524
|
if (!result.success) throw icaclsError(step, result);
|
|
481
525
|
};
|
|
482
526
|
|
|
483
|
-
await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(
|
|
527
|
+
await runOrThrow("/grant:r", [targetPath, "/grant:r", grantAce(principal, directory)]);
|
|
484
528
|
await runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
|
|
485
529
|
|
|
486
530
|
const removal = await run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
|
|
@@ -514,6 +558,8 @@ function sanitizeDiagnostics(error: unknown): string {
|
|
|
514
558
|
return `ACL hardening failed (${code}) — permission denied running icacls`;
|
|
515
559
|
case "EICACLS":
|
|
516
560
|
return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs";
|
|
561
|
+
case "EACLIDENTITY":
|
|
562
|
+
return "ACL hardening failed (EACLIDENTITY) — the effective Windows account SID could not be resolved";
|
|
517
563
|
default:
|
|
518
564
|
return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`;
|
|
519
565
|
}
|
|
@@ -524,6 +570,60 @@ function isTimeoutError(error: unknown): boolean {
|
|
|
524
570
|
&& String((error as NodeJS.ErrnoException).code) === "ETIMEDOUT";
|
|
525
571
|
}
|
|
526
572
|
|
|
573
|
+
/** Preserve only the bounded machine-readable cause on a sanitized public error. */
|
|
574
|
+
function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoException {
|
|
575
|
+
const error = new Error(diagnostics) as NodeJS.ErrnoException;
|
|
576
|
+
const code = cause && typeof cause === "object" && "code" in cause
|
|
577
|
+
? String((cause as { code?: unknown }).code)
|
|
578
|
+
: "";
|
|
579
|
+
// EACLIDENTITY belongs here for the same reason as the rest: a caller that
|
|
580
|
+
// catches a required-mode failure has to tell "the SID could not be resolved"
|
|
581
|
+
// apart from "icacls stalled". Without it the code was dropped and only the
|
|
582
|
+
// message carried the cause, which no caller can branch on.
|
|
583
|
+
if (
|
|
584
|
+
code === "ETIMEDOUT" ||
|
|
585
|
+
code === "EICACLS" ||
|
|
586
|
+
code === "EACCES" ||
|
|
587
|
+
code === "EPERM" ||
|
|
588
|
+
code === "EACLIDENTITY"
|
|
589
|
+
) {
|
|
590
|
+
error.code = code;
|
|
591
|
+
}
|
|
592
|
+
return error;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function previousTimeoutError(retryConsumed: boolean): NodeJS.ErrnoException {
|
|
596
|
+
if (retryConsumed) {
|
|
597
|
+
const error = new Error(
|
|
598
|
+
"ACL hardening skipped — the previous timeout recovery was already consumed",
|
|
599
|
+
) as NodeJS.ErrnoException;
|
|
600
|
+
error.code = "EACLRETRYEXHAUSTED";
|
|
601
|
+
return error;
|
|
602
|
+
}
|
|
603
|
+
return sanitizedAclError(
|
|
604
|
+
"ACL hardening skipped — previous attempt timed out",
|
|
605
|
+
Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }),
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Consume, but never reset, the single explicit recovery attempt for this key. */
|
|
610
|
+
function timeoutMemoErrorIfBlocked(
|
|
611
|
+
memoKey: string,
|
|
612
|
+
opts: HardenOptions,
|
|
613
|
+
): NodeJS.ErrnoException | null {
|
|
614
|
+
const retryConsumed = timedOutPaths.get(memoKey);
|
|
615
|
+
if (retryConsumed === undefined) return null;
|
|
616
|
+
if (opts.retryTimedOutOnce && retryConsumed === false) {
|
|
617
|
+
timedOutPaths.set(memoKey, true);
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
return previousTimeoutError(retryConsumed);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function recordTimeout(memoKey: string): void {
|
|
624
|
+
if (!timedOutPaths.has(memoKey)) timedOutPaths.set(memoKey, false);
|
|
625
|
+
}
|
|
626
|
+
|
|
527
627
|
/**
|
|
528
628
|
* Diagnostic-only post-timeout probe (never promotes to ok:true — a clean /findsid
|
|
529
629
|
* does not prove inheritance was disabled or the user grant ran; only a fully
|
|
@@ -588,10 +688,10 @@ function hardenEntry(
|
|
|
588
688
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
589
689
|
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
590
690
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
if (opts.required) throw
|
|
594
|
-
return { ok: false, diagnostics };
|
|
691
|
+
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
|
|
692
|
+
if (timeoutMemoError) {
|
|
693
|
+
if (opts.required) throw timeoutMemoError;
|
|
694
|
+
return { ok: false, diagnostics: timeoutMemoError.message };
|
|
595
695
|
}
|
|
596
696
|
|
|
597
697
|
const deadline = nowFn() + resolveHardenDeadlineMs();
|
|
@@ -606,6 +706,7 @@ function hardenEntry(
|
|
|
606
706
|
if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
|
|
607
707
|
return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
|
|
608
708
|
}
|
|
709
|
+
timedOutPaths.delete(memoKey);
|
|
609
710
|
return { ok: true };
|
|
610
711
|
} catch (err) {
|
|
611
712
|
// A substitution is not a transient icacls stall; do not spend the retry on it.
|
|
@@ -617,14 +718,14 @@ function hardenEntry(
|
|
|
617
718
|
|
|
618
719
|
const diagnostics = sanitizeDiagnostics(lastErr);
|
|
619
720
|
if (isTimeoutError(lastErr)) {
|
|
620
|
-
|
|
721
|
+
recordTimeout(memoKey);
|
|
621
722
|
const state = describeAclStateAfterTimeout(targetPath, deadline);
|
|
622
723
|
const annotated = `${diagnostics}; ${state}`;
|
|
623
|
-
if (opts.required) throw
|
|
724
|
+
if (opts.required) throw sanitizedAclError(annotated, lastErr);
|
|
624
725
|
console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
|
|
625
726
|
return { ok: false, diagnostics: annotated };
|
|
626
727
|
}
|
|
627
|
-
if (opts.required) throw
|
|
728
|
+
if (opts.required) throw sanitizedAclError(diagnostics, lastErr);
|
|
628
729
|
return { ok: false, diagnostics };
|
|
629
730
|
}
|
|
630
731
|
|
|
@@ -639,10 +740,10 @@ async function hardenEntryAsync(
|
|
|
639
740
|
if (effectivePlatform() !== "win32") return { ok: true };
|
|
640
741
|
if (memoSatisfied(cache, targetPath)) return { ok: true };
|
|
641
742
|
const memoKey = timeoutMemoKey(targetPath, opts);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if (opts.required) throw
|
|
645
|
-
return { ok: false, diagnostics };
|
|
743
|
+
const timeoutMemoError = timeoutMemoErrorIfBlocked(memoKey, opts);
|
|
744
|
+
if (timeoutMemoError) {
|
|
745
|
+
if (opts.required) throw timeoutMemoError;
|
|
746
|
+
return { ok: false, diagnostics: timeoutMemoError.message };
|
|
646
747
|
}
|
|
647
748
|
|
|
648
749
|
const deadline = nowFn() + resolveHardenDeadlineMs();
|
|
@@ -656,6 +757,7 @@ async function hardenEntryAsync(
|
|
|
656
757
|
if (opts.required) throw new Error(SUBSTITUTED_DIAGNOSTIC);
|
|
657
758
|
return { ok: false, diagnostics: SUBSTITUTED_DIAGNOSTIC };
|
|
658
759
|
}
|
|
760
|
+
timedOutPaths.delete(memoKey);
|
|
659
761
|
return { ok: true };
|
|
660
762
|
} catch (err) {
|
|
661
763
|
if (err instanceof Error && err.message === SUBSTITUTED_DIAGNOSTIC) throw err;
|
|
@@ -666,14 +768,14 @@ async function hardenEntryAsync(
|
|
|
666
768
|
|
|
667
769
|
const diagnostics = sanitizeDiagnostics(lastErr);
|
|
668
770
|
if (isTimeoutError(lastErr)) {
|
|
669
|
-
|
|
771
|
+
recordTimeout(memoKey);
|
|
670
772
|
const state = await describeAclStateAfterTimeoutAsync(targetPath, deadline);
|
|
671
773
|
const annotated = `${diagnostics}; ${state}`;
|
|
672
|
-
if (opts.required) throw
|
|
774
|
+
if (opts.required) throw sanitizedAclError(annotated, lastErr);
|
|
673
775
|
console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
|
|
674
776
|
return { ok: false, diagnostics: annotated };
|
|
675
777
|
}
|
|
676
|
-
if (opts.required) throw
|
|
778
|
+
if (opts.required) throw sanitizedAclError(diagnostics, lastErr);
|
|
677
779
|
return { ok: false, diagnostics };
|
|
678
780
|
}
|
|
679
781
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the effective Windows token to the locale-independent SID form that
|
|
3
|
+
* icacls accepts ("*S-1-..."). Environment values such as USERDOMAIN are not
|
|
4
|
+
* an authority for the current token: on workgroup machines USERDOMAIN may be
|
|
5
|
+
* the literal WORKGROUP even though the account belongs to the local computer.
|
|
6
|
+
*
|
|
7
|
+
* There is deliberately NO name-shaped fallback. A `DOMAIN\User` string has a
|
|
8
|
+
* valid shape, but shape is not evidence that the account is the current
|
|
9
|
+
* token's subject, and both environment variables are writable by whatever
|
|
10
|
+
* launched us. A wrong principal here is not cosmetic: `runIcacls` grants it
|
|
11
|
+
* Full Control and then removes inheritance, so a wrong grant either leaves a
|
|
12
|
+
* different account holding the secret or strands the file with no usable ACE.
|
|
13
|
+
* When the SID cannot be resolved, the caller declines to touch the ACL at all.
|
|
14
|
+
*
|
|
15
|
+
* Budget caveat: callers pass their REMAINING harden budget, which becomes the
|
|
16
|
+
* child process timeout. Trusted-executable resolution (a `GetSystemDirectoryW`
|
|
17
|
+
* FFI call) and spawn setup happen before that timeout starts, and the async
|
|
18
|
+
* timer only arms once `Bun.spawn` returns. Both are small in practice, but the
|
|
19
|
+
* lookup is not bounded by the deadline to the microsecond. Tightening that
|
|
20
|
+
* would mean passing an absolute deadline through the runner interface.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { resolveTrustedWindowsPowerShellExe } from "./windows-elevation";
|
|
24
|
+
|
|
25
|
+
const SID_PATTERN = /^S-1-(?:\d+-)+\d+$/i;
|
|
26
|
+
const SID_EXPRESSION =
|
|
27
|
+
"[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value";
|
|
28
|
+
|
|
29
|
+
export interface WindowsPrincipalLookupResult {
|
|
30
|
+
success: boolean;
|
|
31
|
+
exitCode: number | null;
|
|
32
|
+
timedOut: boolean;
|
|
33
|
+
stdout: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type WindowsPrincipalRunner = (
|
|
37
|
+
timeoutMs: number,
|
|
38
|
+
) => WindowsPrincipalLookupResult;
|
|
39
|
+
|
|
40
|
+
export type AsyncWindowsPrincipalRunner = (
|
|
41
|
+
timeoutMs: number,
|
|
42
|
+
) => Promise<WindowsPrincipalLookupResult>;
|
|
43
|
+
|
|
44
|
+
const POWERSHELL_ARGS = [
|
|
45
|
+
"-NoLogo",
|
|
46
|
+
"-NoProfile",
|
|
47
|
+
"-NonInteractive",
|
|
48
|
+
"-WindowStyle",
|
|
49
|
+
"Hidden",
|
|
50
|
+
"-Command",
|
|
51
|
+
SID_EXPRESSION,
|
|
52
|
+
] as const;
|
|
53
|
+
|
|
54
|
+
function windowsPrincipalPowerShellCommand(): string[] {
|
|
55
|
+
return [resolveTrustedWindowsPowerShellExe(), ...POWERSHELL_ARGS];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Test-only readback of the exact trusted executable and static arguments. */
|
|
59
|
+
export function windowsPrincipalPowerShellCommandForTests(): string[] {
|
|
60
|
+
return windowsPrincipalPowerShellCommand();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLookupResult {
|
|
64
|
+
const result = Bun.spawnSync(windowsPrincipalPowerShellCommand(), {
|
|
65
|
+
stdin: "ignore",
|
|
66
|
+
stdout: "pipe",
|
|
67
|
+
stderr: "ignore",
|
|
68
|
+
timeout: Math.max(1, timeoutMs),
|
|
69
|
+
windowsHide: true,
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
success: result.success,
|
|
73
|
+
exitCode: result.exitCode,
|
|
74
|
+
timedOut: result.exitedDueToTimeout ?? false,
|
|
75
|
+
stdout: result.stdout ? result.stdout.toString() : "",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function defaultAsyncWindowsPrincipalRunner(
|
|
80
|
+
timeoutMs: number,
|
|
81
|
+
): Promise<WindowsPrincipalLookupResult> {
|
|
82
|
+
const proc = Bun.spawn(windowsPrincipalPowerShellCommand(), {
|
|
83
|
+
stdin: "ignore",
|
|
84
|
+
stdout: "pipe",
|
|
85
|
+
stderr: "ignore",
|
|
86
|
+
windowsHide: true,
|
|
87
|
+
});
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
timedOut = true;
|
|
91
|
+
try { proc.kill(); } catch { /* already exited */ }
|
|
92
|
+
}, Math.max(1, timeoutMs));
|
|
93
|
+
let exitCode: number | null = null;
|
|
94
|
+
try {
|
|
95
|
+
exitCode = await proc.exited;
|
|
96
|
+
} finally {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
const stdout = proc.stdout
|
|
100
|
+
? await new Response(proc.stdout).text().catch(() => "")
|
|
101
|
+
: "";
|
|
102
|
+
return {
|
|
103
|
+
success: !timedOut && exitCode === 0,
|
|
104
|
+
exitCode: timedOut ? null : exitCode,
|
|
105
|
+
timedOut,
|
|
106
|
+
stdout,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner;
|
|
111
|
+
let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner;
|
|
112
|
+
let cachedPrincipal: string | null = null;
|
|
113
|
+
let asyncLookupInFlight: Promise<string> | null = null;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* POSIX CI drives the Windows ACL branch through `setPlatformForTests("win32")`,
|
|
117
|
+
* on hosts that have neither System32 nor PowerShell. Those runs need SOME
|
|
118
|
+
* principal, so this seam supplies a synthetic one.
|
|
119
|
+
*
|
|
120
|
+
* It lives here rather than in `windows-secret-acl.ts` for one reason that is
|
|
121
|
+
* not cosmetic: an explicitly injected runner must be able to beat it. When the
|
|
122
|
+
* synthetic value was chosen first, in the ACL module, a test could not inject a
|
|
123
|
+
* lookup FAILURE on POSIX at all — so the fail-closed and memo-isolation cases
|
|
124
|
+
* were guarded with `if (process.platform !== "win32") return;` and never ran
|
|
125
|
+
* outside Windows. Resolution order below is what makes those cases executable
|
|
126
|
+
* on every runner.
|
|
127
|
+
*/
|
|
128
|
+
let syntheticPrincipalForTests: string | null = null;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Test seam: supply the principal used when no runner was injected and the host
|
|
132
|
+
* is not really Windows. Pass null to disable.
|
|
133
|
+
*/
|
|
134
|
+
export function setSyntheticWindowsPrincipalForTests(principal: string | null): void {
|
|
135
|
+
syntheticPrincipalForTests = principal;
|
|
136
|
+
cachedPrincipal = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when an explicit runner override is installed and must take precedence. */
|
|
140
|
+
function hasSyncRunnerOverride(): boolean {
|
|
141
|
+
return principalRunner !== defaultWindowsPrincipalRunner;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasAsyncRunnerOverride(): boolean {
|
|
145
|
+
return asyncPrincipalRunner !== defaultAsyncWindowsPrincipalRunner;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function identityError(reason: string): NodeJS.ErrnoException {
|
|
149
|
+
const error = new Error(`Windows effective-account SID lookup ${reason}`) as NodeJS.ErrnoException;
|
|
150
|
+
// Keep identity lookup failures distinct from icacls timeouts. In particular,
|
|
151
|
+
// they must not populate windows-secret-acl's destination timeout memo.
|
|
152
|
+
error.code = "EACLIDENTITY";
|
|
153
|
+
return error;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function principalFromResult(result: WindowsPrincipalLookupResult): string {
|
|
157
|
+
if (!result.success) {
|
|
158
|
+
throw identityError(result.timedOut
|
|
159
|
+
? "timed out"
|
|
160
|
+
: `exited ${result.exitCode ?? "null"}`);
|
|
161
|
+
}
|
|
162
|
+
const sid = result.stdout.trim();
|
|
163
|
+
if (!SID_PATTERN.test(sid)) {
|
|
164
|
+
throw identityError(sid ? "returned an invalid SID" : "returned an empty SID");
|
|
165
|
+
}
|
|
166
|
+
return `*${sid.toUpperCase()}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Resolve and process-cache the effective token SID for synchronous ACL paths. */
|
|
170
|
+
export function resolveCurrentWindowsPrincipal(timeoutMs: number): string {
|
|
171
|
+
// Order matters: an explicitly injected runner outranks the synthetic value,
|
|
172
|
+
// so a test can inject a FAILURE on a POSIX host. See the seam comment above.
|
|
173
|
+
if (hasSyncRunnerOverride()) {
|
|
174
|
+
if (cachedPrincipal) return cachedPrincipal;
|
|
175
|
+
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
|
|
176
|
+
let overridden: WindowsPrincipalLookupResult;
|
|
177
|
+
try {
|
|
178
|
+
overridden = principalRunner(timeoutMs);
|
|
179
|
+
} catch {
|
|
180
|
+
throw identityError("could not start");
|
|
181
|
+
}
|
|
182
|
+
const principal = principalFromResult(overridden);
|
|
183
|
+
cachedPrincipal = principal;
|
|
184
|
+
return principal;
|
|
185
|
+
}
|
|
186
|
+
if (cachedPrincipal) return cachedPrincipal;
|
|
187
|
+
if (syntheticPrincipalForTests) return syntheticPrincipalForTests;
|
|
188
|
+
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
|
|
189
|
+
let result: WindowsPrincipalLookupResult;
|
|
190
|
+
try {
|
|
191
|
+
result = principalRunner(timeoutMs);
|
|
192
|
+
} catch {
|
|
193
|
+
throw identityError("could not start");
|
|
194
|
+
}
|
|
195
|
+
const principal = principalFromResult(result);
|
|
196
|
+
cachedPrincipal = principal;
|
|
197
|
+
return principal;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function waitForExistingLookup(
|
|
201
|
+
lookup: Promise<string>,
|
|
202
|
+
timeoutMs: number,
|
|
203
|
+
): Promise<string> {
|
|
204
|
+
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
|
|
205
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
206
|
+
try {
|
|
207
|
+
return await Promise.race([
|
|
208
|
+
lookup,
|
|
209
|
+
new Promise<never>((_, reject) => {
|
|
210
|
+
timer = setTimeout(
|
|
211
|
+
() => reject(identityError("timed out while awaiting the shared lookup")),
|
|
212
|
+
Math.max(1, timeoutMs),
|
|
213
|
+
);
|
|
214
|
+
}),
|
|
215
|
+
]);
|
|
216
|
+
} finally {
|
|
217
|
+
if (timer) clearTimeout(timer);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Async counterpart. Concurrent callers share one owned child lookup; a later
|
|
223
|
+
* caller may exhaust its own budget without cancelling the lookup owned by the
|
|
224
|
+
* first caller. The first caller owns that child and its process timeout; a
|
|
225
|
+
* later, longer budget deliberately does not extend an already-running child.
|
|
226
|
+
*/
|
|
227
|
+
export async function resolveCurrentWindowsPrincipalAsync(timeoutMs: number): Promise<string> {
|
|
228
|
+
const overridden = hasAsyncRunnerOverride();
|
|
229
|
+
if (cachedPrincipal) return cachedPrincipal;
|
|
230
|
+
if (asyncLookupInFlight) return waitForExistingLookup(asyncLookupInFlight, timeoutMs);
|
|
231
|
+
// Same precedence rule as the sync path: an injected runner beats the synthetic.
|
|
232
|
+
if (!overridden && syntheticPrincipalForTests) return syntheticPrincipalForTests;
|
|
233
|
+
if (timeoutMs <= 0) throw identityError("had no remaining deadline");
|
|
234
|
+
|
|
235
|
+
const lookup = (async (): Promise<string> => {
|
|
236
|
+
let result: WindowsPrincipalLookupResult;
|
|
237
|
+
try {
|
|
238
|
+
result = await asyncPrincipalRunner(timeoutMs);
|
|
239
|
+
} catch {
|
|
240
|
+
throw identityError("could not start");
|
|
241
|
+
}
|
|
242
|
+
const principal = principalFromResult(result);
|
|
243
|
+
cachedPrincipal = principal;
|
|
244
|
+
return principal;
|
|
245
|
+
})();
|
|
246
|
+
asyncLookupInFlight = lookup;
|
|
247
|
+
try {
|
|
248
|
+
return await lookup;
|
|
249
|
+
} finally {
|
|
250
|
+
if (asyncLookupInFlight === lookup) asyncLookupInFlight = null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Test seam: replace the sync resolver process and clear its successful cache. */
|
|
255
|
+
export function setWindowsPrincipalRunnerForTests(
|
|
256
|
+
runner: WindowsPrincipalRunner | null,
|
|
257
|
+
): void {
|
|
258
|
+
if (asyncLookupInFlight) {
|
|
259
|
+
throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
|
|
260
|
+
}
|
|
261
|
+
principalRunner = runner ?? defaultWindowsPrincipalRunner;
|
|
262
|
+
cachedPrincipal = null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Test seam: replace the async resolver process and clear its successful cache. */
|
|
266
|
+
export function setAsyncWindowsPrincipalRunnerForTests(
|
|
267
|
+
runner: AsyncWindowsPrincipalRunner | null,
|
|
268
|
+
): void {
|
|
269
|
+
if (asyncLookupInFlight) {
|
|
270
|
+
throw new Error("Cannot replace the Windows principal runner while a lookup is in flight.");
|
|
271
|
+
}
|
|
272
|
+
asyncPrincipalRunner = runner ?? defaultAsyncWindowsPrincipalRunner;
|
|
273
|
+
cachedPrincipal = null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Test seam: clear only process-local principal state. */
|
|
277
|
+
export function resetWindowsPrincipalForTests(): void {
|
|
278
|
+
if (asyncLookupInFlight) {
|
|
279
|
+
throw new Error("Cannot reset the Windows principal while a lookup is in flight.");
|
|
280
|
+
}
|
|
281
|
+
cachedPrincipal = null;
|
|
282
|
+
syntheticPrincipalForTests = null;
|
|
283
|
+
}
|
package/src/lib/winsw.ts
CHANGED
|
@@ -328,7 +328,23 @@ export async function installWinswService(entry: WinswEntry, deps: WinswInstallD
|
|
|
328
328
|
}
|
|
329
329
|
|
|
330
330
|
export function startWinswService(): void { runWinsw(["start"]); }
|
|
331
|
-
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Stop the native service and prove it is no longer running. `stopwait` can fail both
|
|
334
|
+
* for the benign already-stopped case and for real access/timeout failures, so a bare
|
|
335
|
+
* catch cannot decide whether it is safe for lifecycle callers to continue. Re-read
|
|
336
|
+
* SCM state and only accept the two states that cannot still own the proxy listener.
|
|
337
|
+
*/
|
|
338
|
+
export function stopWinswService(): void {
|
|
339
|
+
try { runWinsw(["stopwait"]); } catch { /* classify by verified state below */ }
|
|
340
|
+
const status = statusWinswRaw();
|
|
341
|
+
if (status === "stopped" || status === "nonexistent") return;
|
|
342
|
+
if (status === "unknown") {
|
|
343
|
+
throw new Error("Native service stop could not be verified.");
|
|
344
|
+
}
|
|
345
|
+
throw new Error("Native service is still running after stop.");
|
|
346
|
+
}
|
|
347
|
+
|
|
332
348
|
export function uninstallWinswService(): void {
|
|
333
349
|
if (!existsSync(winswExePath())) {
|
|
334
350
|
// The binary is gone but the SCM registration can outlive it (quarantine, partial
|
|
@@ -378,4 +394,4 @@ export function winswStatusSummary(): string {
|
|
|
378
394
|
export function defaultWinswEntry(cliDir: string): WinswEntry {
|
|
379
395
|
const runtime = durableBunRuntime();
|
|
380
396
|
return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(cliDir, "cli", "index.ts") };
|
|
381
|
-
}
|
|
397
|
+
}
|
|
@@ -18,9 +18,21 @@ export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLo
|
|
|
18
18
|
* `noReasoningModels`, `defaultModel`) onto a provider config being created, for any field the
|
|
19
19
|
* caller didn't already supply. Lets the vision/reasoning classification actually reach the saved
|
|
20
20
|
* config (the GUI/API only send adapter/baseUrl/apiKey/defaultModel). No-op for unknown names.
|
|
21
|
+
*
|
|
22
|
+
* `modelSupportsReasoningSummaries` is deliberately excluded from what gets persisted. It is
|
|
23
|
+
* registry-only metadata resolved at runtime, and this function feeds a config that is about to
|
|
24
|
+
* be written to disk. Persisting today's registry defaults would freeze them as the user's own
|
|
25
|
+
* overrides: a later registry correction — say we learn a model's backend rejects summary
|
|
26
|
+
* delivery — would never reach anyone who created their provider before the correction, and they
|
|
27
|
+
* would keep getting upstream 400s with no way to know why. Catalog gathering enriches a
|
|
28
|
+
* detached runtime clone, so the defaults still apply where they matter.
|
|
21
29
|
*/
|
|
22
30
|
export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig): void {
|
|
31
|
+
const hadOwnSummaries = Object.hasOwn(prov, "modelSupportsReasoningSummaries");
|
|
32
|
+
const submittedSummaries = prov.modelSupportsReasoningSummaries;
|
|
23
33
|
enrichProviderFromRegistry(name, prov);
|
|
34
|
+
if (hadOwnSummaries) prov.modelSupportsReasoningSummaries = submittedSummaries;
|
|
35
|
+
else delete prov.modelSupportsReasoningSummaries;
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export function isKeyLoginProvider(name: string): boolean {
|