@bitkyc08/opencodex 2.7.25 → 2.7.26-preview.20260719
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-QyUBi3W_.js → index-BbpKHooS.js} +3 -3
- package/gui/dist/assets/{index-Dq3eZ1cU.css → index-BnrJO9Wz.css} +1 -1
- 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
|
@@ -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
|
}
|