@bitkyc08/opencodex 2.7.25 → 2.7.26

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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-QyUBi3W_.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-Dq3eZ1cU.css">
19
+ <script type="module" crossorigin src="/assets/index-BvQ5spEX.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-BnrJO9Wz.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.25",
3
+ "version": "2.7.26",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
package/src/cli/index.ts CHANGED
@@ -87,7 +87,12 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
87
87
  async function chooseListenPort(requestedPort?: number): Promise<number> {
88
88
  const config = loadConfig();
89
89
  const preferred = requestedPort ?? config.port ?? 10100;
90
- const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1");
90
+ // Brief prefer-retry covers stop→start races (update restart, `ocx restart`) where the
91
+ // old process has exited but the listen socket is still draining.
92
+ const selected = await findAvailablePort(preferred, config.hostname ?? "127.0.0.1", {
93
+ preferRetryMs: 750,
94
+ preferRetryIntervalMs: 50,
95
+ });
91
96
  if (selected !== preferred) {
92
97
  console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
93
98
  }
@@ -587,7 +592,7 @@ switch (command) {
587
592
  const jobId = args[1];
588
593
  if (!jobId) process.exit(1);
589
594
  const channel = normalizeUpdateChannel(args[2]);
590
- runGuiUpdateWorker(jobId, channel, args[3] === "restart");
595
+ await runGuiUpdateWorker(jobId, channel, args[3] === "restart");
591
596
  break;
592
597
  }
593
598
  case "restart": {
package/src/config.ts CHANGED
@@ -825,26 +825,84 @@ export function isOcxStartCommandLine(commandLine: string): boolean {
825
825
  return hasOcxEntrypoint && /(?:^|[\s"'])start(?:$|[\s"'])/.test(normalized);
826
826
  }
827
827
 
828
+ /** Per-process memo: waitForProxy/findLiveProxy used to spawn powershell on every 150ms poll. */
829
+ const ocxStartProcessCache = new Map<number, boolean>();
830
+
828
831
  function isLikelyOcxStartProcess(pid: number): boolean {
832
+ const cached = ocxStartProcessCache.get(pid);
833
+ if (cached !== undefined) return cached;
829
834
  const commandLine = readProcessCommandLine(pid);
830
835
  if (commandLine === undefined) return false;
831
- return isOcxStartCommandLine(commandLine);
836
+ const ok = isOcxStartCommandLine(commandLine);
837
+ ocxStartProcessCache.set(pid, ok);
838
+ return ok;
839
+ }
840
+
841
+ /**
842
+ * Alive pid from the pid file without the expensive Windows command-line probe.
843
+ * Safe for liveness polls: callers still identity-check /healthz before trusting the proxy.
844
+ * Destructive stop/kill paths should keep using {@link readPid}, which verifies the cmdline.
845
+ */
846
+ export function readAlivePid(): number | null {
847
+ const pid = readPidFileValue();
848
+ if (pid === null) return null;
849
+ try {
850
+ process.kill(pid, 0);
851
+ return pid;
852
+ } catch (e: unknown) {
853
+ if ((e as NodeJS.ErrnoException).code === "EPERM") return pid;
854
+ return null;
855
+ }
856
+ }
857
+
858
+ /**
859
+ * Full identity check of a KNOWN candidate pid (alive + ocx-start command line).
860
+ * Companion to {@link readAlivePid}: liveness discovery may be cheap, but any pid
861
+ * handed to a destructive caller must pass this check — and must equal the candidate
862
+ * it was asked about, so a pidfile rewrite between discovery and verification can
863
+ * never swap in a different process (TOCTOU guard).
864
+ */
865
+ export function verifyPidIdentity(candidatePid: number): number | null {
866
+ try {
867
+ process.kill(candidatePid, 0);
868
+ } catch (e: unknown) {
869
+ if ((e as NodeJS.ErrnoException).code !== "EPERM") return null;
870
+ }
871
+ return isLikelyOcxStartProcess(candidatePid) ? candidatePid : null;
832
872
  }
833
873
 
834
874
  function readProcessCommandLine(pid: number): string | undefined {
835
875
  try {
836
876
  if (process.platform === "win32") {
877
+ // Prefer WMIC over PowerShell: much faster cold start, and windowsHide avoids console flash.
878
+ // Fall back to PowerShell when WMIC is absent (newer Windows images).
879
+ const wmic = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\wbem\\WMIC.exe`;
880
+ try {
881
+ const output = execFileSync(wmic, [
882
+ "process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/VALUE",
883
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
884
+ const match = /^CommandLine=(.*)$/m.exec(output.replace(/\r/g, ""));
885
+ const value = match?.[1]?.trim();
886
+ if (value) return value;
887
+ } catch {
888
+ /* WMIC missing or failed — fall through */
889
+ }
837
890
  const output = execFileSync("powershell.exe", [
838
891
  "-NoProfile",
892
+ "-NoLogo",
893
+ "-NonInteractive",
894
+ "-WindowStyle",
895
+ "Hidden",
839
896
  "-Command",
840
897
  `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
841
- ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 });
898
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, windowsHide: true });
842
899
  return output.trim() || undefined;
843
900
  }
844
901
  const output = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
845
902
  encoding: "utf-8",
846
903
  stdio: ["ignore", "pipe", "ignore"],
847
904
  timeout: 1000,
905
+ windowsHide: true,
848
906
  });
849
907
  return output.trim() || undefined;
850
908
  } catch {
package/src/lib/errors.ts CHANGED
@@ -57,8 +57,39 @@ function isPermissionMessage(text: string): boolean {
57
57
  );
58
58
  }
59
59
 
60
+ /**
61
+ * Client cancelled / closed the turn. Matches ONLY abort phrases this codebase
62
+ * produces — "client closed request during web-search" (src/web-search/loop.ts),
63
+ * "Client cancelled request" (src/server/responses.ts) — plus the explicit
64
+ * "request cancel(l)ed by client" forms. Deliberately narrow: bare "client closed"
65
+ * would also swallow legitimate upstream failures like "upstream HTTP client
66
+ * closed idle connection" and turn a real 502 into a 499.
67
+ */
68
+ export function isClientClosedMessage(text: string): boolean {
69
+ const lower = text.toLowerCase();
70
+ return (
71
+ lower.includes("client closed request") ||
72
+ lower.includes("client cancelled request") ||
73
+ lower.includes("client canceled request") ||
74
+ lower.includes("request canceled by client") ||
75
+ lower.includes("request cancelled by client")
76
+ );
77
+ }
78
+
60
79
  export function classifyError(status: number, type: string, message: string): OcxErrorPayload {
61
80
  const text = message.toLowerCase();
81
+ // Preserve explicit cancel types used by compact/combo JSON errors; unify message-inferred
82
+ // client closes (web-search abort text) onto client_closed_request for /api/logs.
83
+ if (type === "client_cancelled") {
84
+ return { message, type: "client_cancelled", code: "client_cancelled" };
85
+ }
86
+ if (
87
+ status === 499 ||
88
+ type === "client_closed_request" ||
89
+ isClientClosedMessage(text)
90
+ ) {
91
+ return { message, type: "invalid_request_error", code: "client_closed_request" };
92
+ }
62
93
  if (
63
94
  text.includes("context_length_exceeded") ||
64
95
  text.includes("context window") ||
@@ -166,6 +197,8 @@ export function parseRetryAfterFromMessage(message: string): number | undefined
166
197
  /** Infer HTTP status from adapter terminal error text (provider-agnostic keyword matching). */
167
198
  export function inferHttpStatusFromAdapterMessage(message: string): number {
168
199
  const lower = message.toLowerCase();
200
+ // Client aborts (e.g. mid web-search loop) must not look like upstream 502s in /api/logs.
201
+ if (isClientClosedMessage(lower)) return 499;
169
202
  if (
170
203
  lower.includes("resource_exhausted") ||
171
204
  lower.includes("resource exhausted") ||
@@ -207,17 +240,19 @@ export function adapterFailureFromMessage(message: string): { httpStatus: number
207
240
  if (retryAfterSeconds && !/please try again in /i.test(message)) {
208
241
  finalMessage = `${message} Please try again in ${retryAfterSeconds}s.`;
209
242
  }
210
- const errorType = httpStatus === 429
211
- ? "rate_limit_error"
212
- : httpStatus === 401
213
- ? "authentication_error"
214
- : httpStatus === 403
215
- ? "permission_error"
216
- : httpStatus === 503 || httpStatus === 504
217
- ? "server_error"
218
- : httpStatus === 400
219
- ? "invalid_request_error"
220
- : "upstream_error";
243
+ const errorType = httpStatus === 499
244
+ ? "client_closed_request"
245
+ : httpStatus === 429
246
+ ? "rate_limit_error"
247
+ : httpStatus === 401
248
+ ? "authentication_error"
249
+ : httpStatus === 403
250
+ ? "permission_error"
251
+ : httpStatus === 503 || httpStatus === 504
252
+ ? "server_error"
253
+ : httpStatus === 400
254
+ ? "invalid_request_error"
255
+ : "upstream_error";
221
256
  return {
222
257
  httpStatus,
223
258
  error: classifyError(httpStatus, errorType, finalMessage),
@@ -231,6 +266,7 @@ export function httpStatusFromTerminalError(error: {
231
266
  message?: string;
232
267
  } | undefined): number {
233
268
  if (!error) return 502;
269
+ if (error.code === "client_closed_request" || error.code === "client_cancelled") return 499;
234
270
  if (error.type === "rate_limit_error" || error.code === "rate_limit_exceeded") return 429;
235
271
  if (error.type === "authentication_error" || error.code === "invalid_api_key") return 401;
236
272
  if (
@@ -240,9 +276,12 @@ export function httpStatusFromTerminalError(error: {
240
276
  ) return 403;
241
277
  if (error.type === "insufficient_quota" || error.code === "insufficient_quota") return 429;
242
278
  if (error.type === "server_error" && error.code === "server_is_overloaded") return 503;
279
+ // Client-closed messages often arrive as invalid_request_error after classifyError; check message
280
+ // before treating every invalid_request_error as HTTP 400.
281
+ const message = error.message ?? "";
282
+ if (message && isClientClosedMessage(message)) return 499;
243
283
  if (error.type === "invalid_request_error") return 400;
244
284
  if (error.type === "proxy_error") return 500;
245
- const message = error.message ?? "";
246
285
  if (message) return inferHttpStatusFromAdapterMessage(message);
247
286
  return 502;
248
287
  }
@@ -100,7 +100,7 @@ export function killProxy(pid: number): void {
100
100
  if (process.platform === "win32") {
101
101
  const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
102
102
  try {
103
- execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" });
103
+ execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe", windowsHide: true });
104
104
  } catch (err) {
105
105
  if (isProcessAlive(pid)) throw err;
106
106
  }
package/src/lib/redact.ts CHANGED
@@ -5,8 +5,16 @@ const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-
5
5
  const SECRET_VALUE_PATTERNS: Array<[RegExp, string]> = [
6
6
  [/\bBearer\s+[A-Za-z0-9._~+/=-]{8,}\b/gi, `Bearer ${REDACTED_SECRET}`],
7
7
  [/\b(sk-[A-Za-z0-9][A-Za-z0-9._-]{6,})\b/g, REDACTED_SECRET],
8
+ // GitHub tokens (classic + fine-grained + OAuth/refresh): ghp_/gho_/ghu_/ghs_/ghr_/github_pat_.
9
+ [/\b(gh[pousr]_[A-Za-z0-9_]{8,}|github_pat_[A-Za-z0-9_]{20,})\b/g, REDACTED_SECRET],
10
+ // GitHub Copilot API tokens: semicolon-joined k=v grammar starting with tid=…
11
+ // (e.g. "tid=abc123;exp=1699999999;sku=copilot_pro;…:sig"). Redact the whole token —
12
+ // a Bearer-prefix rule alone leaves the suffix intact.
13
+ [/\btid=[A-Za-z0-9-]+(?:;[A-Za-z0-9_.-]+=[^;\s"']*)+(?::[A-Za-z0-9+/=_-]+)?/g, REDACTED_SECRET],
8
14
  [/\b((?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|refreshToken|accessToken|clientSecret|apiKey)=)([^&\s"',;]+)/gi, `$1${REDACTED_SECRET}`],
9
15
  [/((?:"(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|refreshToken|accessToken|clientSecret|apiKey)"\s*:\s*"))([^"]+)(")/gi, `$1${REDACTED_SECRET}$3`],
16
+ // Raw JSON "token" field values (Copilot token exchange bodies echo the credential here).
17
+ [/(("token"\s*:\s*"))([^"]+)(")/gi, `$1${REDACTED_SECRET}$4`],
10
18
  [/\b(arn:aws:[A-Za-z0-9_-]+:[A-Za-z0-9-]*:\d{12}:[A-Za-z0-9_/:+=,.@-]+)\b/g, REDACTED_SECRET],
11
19
  ];
12
20
 
@@ -14,16 +14,21 @@
14
14
  * hardenSecretPath(path, { required: false }) — non-fatal read-path mode.
15
15
  * Never throws. Returns { ok, diagnostics? }.
16
16
  * hardenSecretPath(path, { required: true }) — write-path mode.
17
- * Throws a sanitized error (no raw path) on Windows ACL failure.
17
+ * Throws a sanitized error (no raw path) on Windows ACL failure — EXCEPT a
18
+ * genuine icacls timeout, which soft-fails (warn + ok:false) so a hung/slow
19
+ * icacls cannot block OAuth logins or token refresh (field report: Kimi auth
20
+ * stuck behind ETIMEDOUT). Real EPERM/EACCES/exit-code failures still throw:
21
+ * availability never silently overrides confidentiality for those.
18
22
  * hardenSecretDir — same contract for directories.
19
23
  */
20
24
 
21
- import { execFileSync } from "node:child_process";
22
25
  import { existsSync } from "node:fs";
23
26
  import { env, platform } from "node:process";
24
27
 
25
28
  const hardenedDirectories = new Set<string>();
26
29
  const hardenedPaths = new Set<string>();
30
+ /** Paths whose harden TIMED OUT this process: do not re-stall every loadConfig on them. */
31
+ const timedOutPaths = new Set<string>();
27
32
 
28
33
  export interface HardenResult {
29
34
  ok: boolean;
@@ -34,6 +39,92 @@ export interface HardenOptions {
34
39
  required: boolean;
35
40
  }
36
41
 
42
+ /**
43
+ * Total icacls budget per harden call — ALL steps share it, including the single
44
+ * timeout retry and the diagnostic verification pass (no per-attempt fresh budget:
45
+ * loadConfig hardens dir+config+auth sequentially, so per-attempt budgets stack
46
+ * into multi-minute startup stalls). Override with OPENCODEX_ACL_TIMEOUT_MS
47
+ * (integer ms, clamped to [1000, 60000]; invalid values fall back to 5000).
48
+ */
49
+ const HARDEN_DEADLINE_DEFAULT_MS = 5_000;
50
+ const HARDEN_DEADLINE_MIN_MS = 1_000;
51
+ const HARDEN_DEADLINE_MAX_MS = 60_000;
52
+
53
+ /** Resolve the total harden budget once per call (env mutation cannot change it midway). */
54
+ function resolveHardenDeadlineMs(): number {
55
+ const raw = env["OPENCODEX_ACL_TIMEOUT_MS"]?.trim();
56
+ if (!raw) return HARDEN_DEADLINE_DEFAULT_MS;
57
+ const parsed = Number(raw);
58
+ if (!Number.isSafeInteger(parsed)) return HARDEN_DEADLINE_DEFAULT_MS;
59
+ return Math.min(HARDEN_DEADLINE_MAX_MS, Math.max(HARDEN_DEADLINE_MIN_MS, parsed));
60
+ }
61
+
62
+ export interface IcaclsResult {
63
+ success: boolean;
64
+ exitCode: number | null;
65
+ timedOut: boolean;
66
+ stdout: string;
67
+ }
68
+
69
+ type IcaclsRunner = (args: string[], timeoutMs: number) => IcaclsResult;
70
+
71
+ function defaultIcaclsRunner(args: string[], timeoutMs: number): IcaclsResult {
72
+ // Bun.spawnSync with windowsHide: Node execFileSync has hung under the GUI/proxy even
73
+ // with windowsHide, and console-subsystem tools flash a visible window otherwise.
74
+ const result = Bun.spawnSync(["icacls.exe", ...args], {
75
+ stdin: "ignore",
76
+ stdout: "pipe",
77
+ stderr: "ignore",
78
+ timeout: timeoutMs,
79
+ windowsHide: true,
80
+ });
81
+ return {
82
+ success: result.success,
83
+ exitCode: result.exitCode,
84
+ timedOut: result.exitedDueToTimeout ?? false,
85
+ stdout: result.stdout ? result.stdout.toString() : "",
86
+ };
87
+ }
88
+
89
+ let icaclsRunner: IcaclsRunner = defaultIcaclsRunner;
90
+ let platformOverride: string | null = null;
91
+ let nowFn: () => number = Date.now;
92
+
93
+ /** Test seam: replace the icacls process runner. Pass null to restore the default. */
94
+ export function setIcaclsRunnerForTests(runner: IcaclsRunner | null): void {
95
+ icaclsRunner = runner ?? defaultIcaclsRunner;
96
+ }
97
+
98
+ /** Test seam: force the platform gate (e.g. "win32") so CI on POSIX reaches the runner. */
99
+ export function setPlatformForTests(value: string | null): void {
100
+ platformOverride = value;
101
+ }
102
+
103
+ /** Test seam: injectable clock for deadline tests (no real sleeps). */
104
+ export function setNowForTests(fn: (() => number) | null): void {
105
+ nowFn = fn ?? Date.now;
106
+ }
107
+
108
+ /** Test seam: clear memo/failure caches between cases. */
109
+ export function resetHardenedStateForTests(): void {
110
+ hardenedDirectories.clear();
111
+ hardenedPaths.clear();
112
+ timedOutPaths.clear();
113
+ }
114
+
115
+ function effectivePlatform(): string {
116
+ return platformOverride ?? platform;
117
+ }
118
+
119
+ /** Error carrying an honest code: ETIMEDOUT only for real timeouts, EICACLS otherwise. */
120
+ function icaclsError(step: string, result: IcaclsResult): NodeJS.ErrnoException {
121
+ const err = new Error(
122
+ result.timedOut ? `icacls ${step} timed out` : `icacls ${step} exited ${result.exitCode ?? "null"}`,
123
+ ) as NodeJS.ErrnoException;
124
+ err.code = result.timedOut ? "ETIMEDOUT" : "EICACLS";
125
+ return err;
126
+ }
127
+
37
128
  /**
38
129
  * Return the current Windows username from the environment.
39
130
  * Falls back to USERDOMAIN\USERNAME if USERNAME alone is ambiguous.
@@ -58,39 +149,52 @@ function currentWindowsUser(): string | undefined {
58
149
  *
59
150
  * Throws the raw child_process error on failure (caller sanitizes).
60
151
  */
61
- function runIcacls(targetPath: string, directory: boolean): void {
152
+ const BROAD_SIDS = ["*S-1-1-0", "*S-1-5-11", "*S-1-5-32-545"] as const;
153
+
154
+ function runIcacls(targetPath: string, directory: boolean, deadline: number): void {
62
155
  const user = currentWindowsUser();
63
156
  if (!user) {
64
157
  throw new Error("Cannot determine current Windows user for ACL hardening");
65
158
  }
66
159
 
160
+ // The deadline is owned by hardenEntry (total budget incl. retry + verification).
161
+ const run = (step: string, args: string[]): IcaclsResult => {
162
+ const remaining = deadline - nowFn();
163
+ if (remaining <= 0) {
164
+ throw icaclsError(step, { success: false, exitCode: null, timedOut: true, stdout: "" });
165
+ }
166
+ return icaclsRunner(args, remaining);
167
+ };
168
+ const runOrThrow = (step: string, args: string[]): void => {
169
+ const result = run(step, args);
170
+ if (!result.success) throw icaclsError(step, result);
171
+ };
172
+
67
173
  // Step 1: disable inheritance and remove inherited ACEs
68
- execFileSync("icacls.exe", [targetPath, "/inheritance:r"], {
69
- stdio: ["ignore", "pipe", "ignore"],
70
- timeout: 5000,
71
- shell: false,
72
- });
174
+ runOrThrow("/inheritance:r", [targetPath, "/inheritance:r"]);
73
175
 
74
176
  // Step 2: remove broad explicit grants using stable SIDs (not localized names).
75
- execFileSync("icacls.exe", [
76
- targetPath,
77
- "/remove:g",
78
- "*S-1-1-0",
79
- "*S-1-5-11",
80
- "*S-1-5-32-545",
81
- ], {
82
- stdio: ["ignore", "pipe", "ignore"],
83
- timeout: 5000,
84
- shell: false,
85
- });
177
+ // Missing ACEs can yield a non-zero exit; verify with locale-independent /findsid
178
+ // before accepting the failure as harmless — a swallowed real failure would leave
179
+ // Everyone/Users/Authenticated Users grants while reporting hardened.
180
+ const removal = run("/remove:g", [targetPath, "/remove:g", ...BROAD_SIDS]);
181
+ if (!removal.success) {
182
+ if (removal.timedOut) throw icaclsError("/remove:g", removal);
183
+ for (const sid of BROAD_SIDS) {
184
+ const found = run("/findsid", [targetPath, "/findsid", sid]);
185
+ if (!found.success) throw icaclsError("/findsid", found);
186
+ // icacls /findsid echoes the target path in its "SID Found" line only when the SID
187
+ // still holds an ACE; the summary lines carry only counts. Matching the path echo —
188
+ // not the (localized) prose — keeps the check locale-independent.
189
+ if (found.stdout.includes(targetPath)) {
190
+ throw icaclsError("/remove:g", removal);
191
+ }
192
+ }
193
+ }
86
194
 
87
195
  // Step 3: grant current user full control.
88
196
  const grant = directory ? `${user}:(OI)(CI)(F)` : `${user}:(F)`;
89
- execFileSync("icacls.exe", [targetPath, "/grant:r", grant], {
90
- stdio: ["ignore", "pipe", "ignore"],
91
- timeout: 5000,
92
- shell: false,
93
- });
197
+ runOrThrow("/grant:r", [targetPath, "/grant:r", grant]);
94
198
  }
95
199
 
96
200
  /**
@@ -99,44 +203,105 @@ function runIcacls(targetPath: string, directory: boolean): void {
99
203
  * sensitive username components or PII from the home directory path).
100
204
  */
101
205
  function sanitizeDiagnostics(error: unknown): string {
102
- // We do not expose the raw error message or any path-like fragments.
103
- // Just describe what failed generically.
206
+ // We do not expose the raw error message or any path-like fragments
207
+ // just an honest, code-specific cause (issue #160: a transient icacls stall
208
+ // must not read like filesystem non-support).
104
209
  const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code) : "";
105
- const codePart = code ? ` (${code})` : "";
106
- return `ACL hardening failed${codePart} — filesystem may not support per-user NTFS ACLs`;
210
+ switch (code) {
211
+ case "ETIMEDOUT":
212
+ return "ACL hardening timed out (ETIMEDOUT) — transient icacls stall; the volume may still support per-user NTFS ACLs";
213
+ case "EPERM":
214
+ case "EACCES":
215
+ return `ACL hardening failed (${code}) — permission denied running icacls`;
216
+ case "EICACLS":
217
+ return "ACL hardening failed (EICACLS) — icacls command error; filesystem may not support per-user NTFS ACLs";
218
+ default:
219
+ return `ACL hardening failed${code ? ` (${code})` : ""} — filesystem may not support per-user NTFS ACLs`;
220
+ }
221
+ }
222
+
223
+ function isTimeoutError(error: unknown): boolean {
224
+ return error instanceof Error && "code" in error
225
+ && String((error as NodeJS.ErrnoException).code) === "ETIMEDOUT";
107
226
  }
108
227
 
109
228
  /**
110
- * Harden a single file path with per-user NTFS ACLs on Windows.
111
- * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
112
- *
113
- * @param targetPath Absolute path to the file to harden.
114
- * @param opts { required: boolean } — required:true throws on failure.
229
+ * Diagnostic-only post-timeout probe (never promotes to ok:true a clean /findsid
230
+ * does not prove inheritance was disabled or the user grant ran; only a fully
231
+ * completed harden sequence may enter the hardened cache). Bounded by the remaining
232
+ * total budget; returns a short state note for the soft-fail diagnostic.
115
233
  */
116
- export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
117
- // Skip for missing files — we cannot harden what does not exist yet.
118
- if (!existsSync(targetPath)) {
119
- return { ok: true };
234
+ function describeAclStateAfterTimeout(targetPath: string, deadline: number): string {
235
+ try {
236
+ for (const sid of BROAD_SIDS) {
237
+ const remaining = deadline - nowFn();
238
+ if (remaining <= 0) return "ACL state unverified (budget exhausted)";
239
+ const found = icaclsRunner([targetPath, "/findsid", sid], remaining);
240
+ if (!found.success) return "ACL state unverified (probe failed)";
241
+ if (found.stdout.includes(targetPath)) return "broad ACL grants still present";
242
+ }
243
+ return "no broad ACL grants detected (hardening still incomplete)";
244
+ } catch {
245
+ return "ACL state unverified (probe failed)";
120
246
  }
247
+ }
121
248
 
122
- // Non-Windows: no NTFS ACLs; caller handles chmod.
123
- if (platform !== "win32") {
124
- return { ok: true };
249
+ /**
250
+ * Shared harden flow for files and directories: one total budget (env-configurable)
251
+ * covering the initial attempt, ONE timeout retry, and the diagnostic verification.
252
+ * Real EPERM/EACCES/EICACLS failures stay fail-closed on required paths; only
253
+ * genuine timeouts soft-fail, with an honest state-annotated diagnostic.
254
+ */
255
+ function hardenEntry(
256
+ targetPath: string,
257
+ directory: boolean,
258
+ opts: HardenOptions,
259
+ cache: Set<string>,
260
+ ): HardenResult {
261
+ if (!existsSync(targetPath)) return { ok: true };
262
+ if (effectivePlatform() !== "win32") return { ok: true };
263
+ if (cache.has(targetPath)) return { ok: true };
264
+ if (timedOutPaths.has(targetPath)) {
265
+ return { ok: false, diagnostics: "ACL hardening skipped — previous attempt timed out" };
125
266
  }
126
267
 
127
- if (hardenedPaths.has(targetPath)) return { ok: true };
128
-
129
- try {
130
- runIcacls(targetPath, false);
131
- hardenedPaths.add(targetPath);
132
- return { ok: true };
133
- } catch (err) {
134
- const diagnostics = sanitizeDiagnostics(err);
135
- if (opts.required) {
136
- throw new Error(diagnostics);
268
+ const deadline = nowFn() + resolveHardenDeadlineMs();
269
+ let lastErr: unknown;
270
+ for (let attempt = 0; attempt < 2; attempt++) {
271
+ if (attempt > 0 && deadline - nowFn() <= 0) break; // retry only while budget remains
272
+ try {
273
+ runIcacls(targetPath, directory, deadline);
274
+ cache.add(targetPath);
275
+ return { ok: true };
276
+ } catch (err) {
277
+ lastErr = err;
278
+ if (!isTimeoutError(err)) break; // real failures do not retry
137
279
  }
138
- return { ok: false, diagnostics };
139
280
  }
281
+
282
+ const diagnostics = sanitizeDiagnostics(lastErr);
283
+ if (isTimeoutError(lastErr)) {
284
+ timedOutPaths.add(targetPath);
285
+ const state = describeAclStateAfterTimeout(targetPath, deadline);
286
+ const annotated = `${diagnostics}; ${state}`;
287
+ // Timeout-only soft-fail: a hung icacls must not block OAuth/token writes.
288
+ // chmod is still applied by the caller.
289
+ console.warn(`[opencodex] ${annotated} — continuing without NTFS ACL harden`);
290
+ return { ok: false, diagnostics: annotated };
291
+ }
292
+ if (opts.required) throw new Error(diagnostics);
293
+ return { ok: false, diagnostics };
294
+ }
295
+
296
+ /**
297
+ * Harden a single file path with per-user NTFS ACLs on Windows.
298
+ * On non-Windows platforms, returns ok:true immediately (caller owns chmod).
299
+ *
300
+ * @param targetPath Absolute path to the file to harden.
301
+ * @param opts { required: boolean } — required:true throws on failure.
302
+ */
303
+ export function hardenSecretPath(targetPath: string, opts: HardenOptions): HardenResult {
304
+ return hardenEntry(targetPath, false, opts, hardenedPaths);
140
305
  }
141
306
 
142
307
  /**
@@ -147,27 +312,5 @@ export function hardenSecretPath(targetPath: string, opts: HardenOptions): Harde
147
312
  * @param opts { required: boolean } — required:true throws on failure.
148
313
  */
149
314
  export function hardenSecretDir(targetPath: string, opts: HardenOptions): HardenResult {
150
- // Skip for missing directories — we cannot harden what does not exist yet.
151
- if (!existsSync(targetPath)) {
152
- return { ok: true };
153
- }
154
-
155
- // Non-Windows: no NTFS ACLs; caller handles chmod.
156
- if (platform !== "win32") {
157
- return { ok: true };
158
- }
159
-
160
- if (hardenedDirectories.has(targetPath)) return { ok: true };
161
-
162
- try {
163
- runIcacls(targetPath, true);
164
- hardenedDirectories.add(targetPath);
165
- return { ok: true };
166
- } catch (err) {
167
- const diagnostics = sanitizeDiagnostics(err);
168
- if (opts.required) {
169
- throw new Error(diagnostics);
170
- }
171
- return { ok: false, diagnostics };
172
- }
315
+ return hardenEntry(targetPath, true, opts, hardenedDirectories);
173
316
  }