@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.
- package/gui/dist/assets/{index-Dq3eZ1cU.css → index-BnrJO9Wz.css} +1 -1
- package/gui/dist/assets/{index-QyUBi3W_.js → index-BvQ5spEX.js} +3 -3
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +7 -2
- package/src/config.ts +60 -2
- package/src/lib/errors.ts +51 -12
- package/src/lib/process-control.ts +1 -1
- package/src/lib/redact.ts +8 -0
- package/src/lib/windows-secret-acl.ts +217 -74
- package/src/oauth/github-copilot.ts +427 -0
- package/src/oauth/index.ts +41 -4
- package/src/oauth/kimi.ts +52 -1
- package/src/oauth/store.ts +20 -3
- package/src/oauth/types.ts +5 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/quota.ts +111 -59
- package/src/providers/registry.ts +56 -2
- package/src/providers/xai-transport.ts +5 -0
- package/src/server/ports.ts +44 -2
- package/src/server/proxy-liveness.ts +26 -4
- package/src/server/request-log.ts +20 -6
- package/src/server/responses.ts +14 -5
- package/src/update/job.ts +59 -11
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
211
|
-
? "
|
|
212
|
-
: httpStatus ===
|
|
213
|
-
? "
|
|
214
|
-
: httpStatus ===
|
|
215
|
-
? "
|
|
216
|
-
: httpStatus ===
|
|
217
|
-
? "
|
|
218
|
-
: httpStatus ===
|
|
219
|
-
? "
|
|
220
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
}
|