@bitkyc08/opencodex 2.78.0 → 2.80.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.
Files changed (91) hide show
  1. package/gui/dist/assets/App-CSJvmjNs.css +1 -0
  2. package/gui/dist/assets/App-MbDVfUeC.js +54 -0
  3. package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-CzkxK82A.js} +1 -1
  4. package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
  5. package/gui/dist/assets/index-UJt6npXk.js +94 -0
  6. package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-DU3sD6Z-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/adapters/cursor/live-transport.ts +5 -3
  10. package/src/adapters/cursor/native-exec-fs.ts +12 -9
  11. package/src/adapters/cursor/native-exec.ts +13 -5
  12. package/src/adapters/cursor/protobuf-request.ts +13 -7
  13. package/src/adapters/cursor/request-builder.ts +23 -10
  14. package/src/adapters/cursor/tool-definitions.ts +9 -6
  15. package/src/adapters/cursor/tool-guidance.ts +29 -8
  16. package/src/adapters/cursor/tool-naming.ts +40 -7
  17. package/src/adapters/cursor/tool-wording.ts +46 -0
  18. package/src/adapters/cursor.ts +3 -0
  19. package/src/adapters/devin.ts +37 -2
  20. package/src/adapters/openai-chat/messages.ts +3 -1
  21. package/src/claude/desktop-picker.ts +11 -9
  22. package/src/claude/inbound-content-options.ts +29 -2
  23. package/src/claude/inbound.ts +9 -0
  24. package/src/claude/intercept/local-ca-files.ts +265 -0
  25. package/src/claude/intercept/local-ca-windows.ts +73 -0
  26. package/src/claude/intercept/local-ca.ts +12 -24
  27. package/src/claude/intercept/picker-ca-persistence.ts +106 -0
  28. package/src/claude/intercept/picker-ca-startup.ts +16 -0
  29. package/src/claude/intercept/picker-ca-store.ts +70 -0
  30. package/src/claude/intercept/picker-ca.ts +43 -9
  31. package/src/claude/intercept/picker-runtime.ts +14 -4
  32. package/src/claude/intercept/runtime.ts +32 -23
  33. package/src/cli/ensure-readiness.ts +48 -0
  34. package/src/cli/gui-pair-client.ts +15 -7
  35. package/src/cli/gui.ts +4 -1
  36. package/src/cli/hub.ts +1 -0
  37. package/src/cli/index.ts +10 -11
  38. package/src/cli/status.ts +2 -2
  39. package/src/cli/update-restart-child.ts +4 -1
  40. package/src/codex/account-credit-use.ts +8 -2
  41. package/src/codex/auth-api/account-pause-group.ts +113 -0
  42. package/src/codex/auth-api/routes.ts +18 -12
  43. package/src/codex/auth-context.ts +45 -3
  44. package/src/codex/autostart-health.ts +13 -3
  45. package/src/codex/journal.ts +36 -5
  46. package/src/codex/pool-credit-policy.ts +67 -0
  47. package/src/codex/shim-overlay.ts +9 -1
  48. package/src/combos/jev.ts +3 -1
  49. package/src/generated/compatibility-version.json +135 -63
  50. package/src/lib/gui-pair-capability.ts +1 -1
  51. package/src/lib/gui-pair-intent.ts +170 -0
  52. package/src/lib/windows-owner-acl.ts +143 -0
  53. package/src/providers/openai-sidecar-credit.ts +26 -0
  54. package/src/providers/openai-sidecar.ts +35 -0
  55. package/src/server/claude-messages.ts +1 -0
  56. package/src/server/gui-pair-delivery.ts +23 -0
  57. package/src/server/index/serve-options.ts +5 -8
  58. package/src/server/index.ts +12 -14
  59. package/src/server/live-native-calls.ts +94 -0
  60. package/src/server/live.ts +28 -5
  61. package/src/server/local-management-read-client.ts +15 -11
  62. package/src/server/management/oauth-account-routes.ts +15 -0
  63. package/src/server/management/route-registry.ts +1 -0
  64. package/src/server/management/shared.ts +9 -3
  65. package/src/server/messages-native-scope.ts +60 -0
  66. package/src/server/messages-native.ts +9 -0
  67. package/src/server/responses/adapter-dispatch.ts +31 -2
  68. package/src/server/responses/combo-requested-effort.ts +14 -0
  69. package/src/server/responses/compact.ts +17 -2
  70. package/src/server/responses/core-auth.ts +18 -1
  71. package/src/server/responses/core-codex-account.ts +18 -8
  72. package/src/server/responses/core-combo.ts +5 -7
  73. package/src/server/responses/non-replayable-error.ts +48 -0
  74. package/src/server/responses/passthrough-delivery.ts +5 -1
  75. package/src/server/responses/passthrough-dispatch.ts +21 -19
  76. package/src/server/responses/request-prepare.ts +11 -1
  77. package/src/server/responses/sidecar-execution.ts +1 -0
  78. package/src/server/search.ts +4 -0
  79. package/src/server/startup-health-cache.ts +2 -6
  80. package/src/service/desktop-startup.ts +100 -3
  81. package/src/vision/describe.ts +17 -10
  82. package/src/vision/index.ts +1 -0
  83. package/src/web-search/executor.ts +17 -10
  84. package/src/web-search/format-result.ts +304 -20
  85. package/src/web-search/header-deadline.ts +67 -0
  86. package/src/web-search/loop.ts +7 -6
  87. package/src/web-search/passthrough-bridge.ts +1 -0
  88. package/src/web-search/run-turn-loop.ts +1 -1
  89. package/gui/dist/assets/App-BtfG5id-.js +0 -54
  90. package/gui/dist/assets/App-lCIslZ1L.css +0 -1
  91. package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
@@ -0,0 +1,265 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync, type BigIntStats } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { withClientLifecycleSync } from "../../client/lifecycle-lock";
5
+ import { forgetEphemeralSecretPath, hardenSecretDir, hardenSecretPath, windowsSecretAclApplies } from "../../lib/windows-secret-acl";
6
+ import { assertLocalCaWindowsAcl } from "./local-ca-windows";
7
+
8
+ const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
9
+ const NONBLOCK = constants.O_NONBLOCK ?? 0;
10
+ const MAX_PEM_BYTES = 64 * 1024;
11
+ const KEY = "ca.key";
12
+ const CERT = "ca.pem";
13
+ type FileMode = 0o600 | 0o644;
14
+ type AclLevel = "owner" | "inherited" | "private";
15
+ type AclCheck = ((path: string, stat: BigIntStats, level?: AclLevel) => void) & { forget(stat: BigIntStats): void };
16
+ type FileHooks = { beforeRead?: (path: string) => void; beforeWrite?: (path: string) => void; beforePublish?: (path: string) => void };
17
+ let hooks: FileHooks = {};
18
+ /** Boundary fault injection for substitution and zero-secret-write assertions. */
19
+ export function setLocalCaFileHooksForTests(next: FileHooks | null): void { hooks = next ?? {}; }
20
+
21
+ function unsafe(): Error {
22
+ return Object.assign(new Error("Local CA requires safe owner-controlled files and directory"), { code: "local_ca_path_unsafe" });
23
+ }
24
+ function missing(error: unknown): boolean { return (error as NodeJS.ErrnoException)?.code === "ENOENT"; }
25
+ function owned(stat: BigIntStats, directory = false, mode: FileMode = 0o600): void {
26
+ if (stat.isSymbolicLink() || !(directory ? stat.isDirectory() : stat.isFile() && stat.nlink === 1n)) throw unsafe();
27
+ if (!windowsSecretAclApplies()) {
28
+ if (typeof process.geteuid !== "function" || stat.uid !== BigInt(process.geteuid())) throw unsafe();
29
+ const forbidden = directory || mode === 0o600 ? 0o077 : 0o022;
30
+ if ((Number(stat.mode) & forbidden) !== 0) throw unsafe();
31
+ }
32
+ }
33
+ function same(path: string, expected: BigIntStats, directory = false, mode: FileMode = 0o600): BigIntStats {
34
+ const stat = lstatSync(path, { bigint: true });
35
+ owned(stat, directory, mode);
36
+ if (stat.dev !== expected.dev || stat.ino !== expected.ino || stat.ino === 0n) throw unsafe();
37
+ return stat;
38
+ }
39
+ function inspect(path: string, mode: FileMode, checkAcl: AclCheck, sqliteSidecar = false): BigIntStats | null {
40
+ let stat: BigIntStats;
41
+ try { stat = lstatSync(path, { bigint: true }); } catch (error) { if (missing(error)) return null; throw error; }
42
+ owned(stat, false, mode);
43
+ if (windowsSecretAclApplies()) {
44
+ if (sqliteSidecar) checkAcl(path, stat, "inherited");
45
+ else {
46
+ try { checkAcl(path, stat); }
47
+ catch {
48
+ // Legacy files may inherit only trusted grants; verify before narrowing.
49
+ checkAcl(path, stat, "inherited");
50
+ same(path, stat, false, mode);
51
+ hardenSecretPath(path, { required: true });
52
+ same(path, stat, false, mode);
53
+ checkAcl(path, stat);
54
+ }
55
+ }
56
+ }
57
+ same(path, stat, false, mode);
58
+ return stat;
59
+ }
60
+
61
+ function protectNew(path: string, fd: number, mode: FileMode, checkAcl: AclCheck): void {
62
+ const created = fstatSync(fd, { bigint: true });
63
+ owned(created, false, mode);
64
+ if (windowsSecretAclApplies()) {
65
+ // Only O_CREAT|O_EXCL entries reach here. Narrow the empty file we created,
66
+ // then strictly verify before writing; an Administrators default owner still fails closed.
67
+ same(path, created, false, mode);
68
+ hardenSecretPath(path, { required: true });
69
+ checkAcl(path, created);
70
+ } else fchmodSync(fd, mode);
71
+ same(path, created, false, mode);
72
+ }
73
+
74
+ function readPinned(path: string, expected: BigIntStats, mode: FileMode, assertDirectory: () => void, checkAcl: AclCheck): string {
75
+ hooks.beforeRead?.(path);
76
+ assertDirectory();
77
+ same(path, expected, false, mode);
78
+ const fd = openSync(path, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
79
+ try {
80
+ const opened = fstatSync(fd, { bigint: true });
81
+ owned(opened, false, mode);
82
+ if (opened.dev !== expected.dev || opened.ino !== expected.ino || opened.size > BigInt(MAX_PEM_BYTES)) throw unsafe();
83
+ checkAcl(path, opened);
84
+ same(path, opened, false, mode);
85
+ const bytes = Buffer.alloc(MAX_PEM_BYTES + 1);
86
+ let length = 0;
87
+ while (length < bytes.length) {
88
+ const count = readSync(fd, bytes, length, bytes.length - length, length);
89
+ if (!count) break;
90
+ length += count;
91
+ }
92
+ const after = fstatSync(fd, { bigint: true });
93
+ owned(after, false, mode);
94
+ if (length > MAX_PEM_BYTES || BigInt(length) !== after.size || after.mtimeNs !== opened.mtimeNs || after.ctimeNs !== opened.ctimeNs) throw unsafe();
95
+ same(path, opened, false, mode);
96
+ assertDirectory();
97
+ return bytes.subarray(0, length).toString("utf8");
98
+ } finally { closeSync(fd); }
99
+ }
100
+
101
+ export interface LocalCaFiles {
102
+ readPair(): { certPem: string; keyPem: string } | null;
103
+ writePair(pair: { certPem: string; keyPem: string }): void;
104
+ }
105
+
106
+ function stage(path: string, contents: string, mode: FileMode, assertDirectory: () => void, checkAcl: AclCheck): { publish(): void; dispose(): void } {
107
+ assertDirectory();
108
+ const previous = inspect(path, mode, checkAcl);
109
+ const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`);
110
+ const fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK, mode);
111
+ let created: BigIntStats;
112
+ try { created = fstatSync(fd, { bigint: true }); }
113
+ catch (error) { closeSync(fd); throw error; }
114
+ let closed = false;
115
+ let removed = false;
116
+ const dispose = (): void => {
117
+ if (!closed) { closed = true; closeSync(fd); }
118
+ if (removed) return;
119
+ try { same(temp, created, false, mode); unlinkSync(temp); removed = true; }
120
+ catch (error) { if (missing(error)) removed = true; else throw error; }
121
+ if (removed) { checkAcl.forget(created); forgetEphemeralSecretPath(temp); }
122
+ };
123
+ try {
124
+ protectNew(temp, fd, mode, checkAcl);
125
+ assertDirectory();
126
+ hooks.beforeWrite?.(temp);
127
+ same(temp, created, false, mode);
128
+ assertDirectory();
129
+ checkAcl(temp, created);
130
+ writeFileSync(fd, contents, { encoding: "utf8" });
131
+ same(temp, created, false, mode);
132
+ return {
133
+ publish() {
134
+ hooks.beforePublish?.(path);
135
+ assertDirectory();
136
+ same(temp, created, false, mode);
137
+ checkAcl(temp, created);
138
+ const current = inspect(path, mode, checkAcl);
139
+ if (previous ? !current || previous.dev !== current.dev || previous.ino !== current.ino : current !== null) throw unsafe();
140
+ renameSync(temp, path);
141
+ if (current) checkAcl.forget(current);
142
+ removed = true;
143
+ forgetEphemeralSecretPath(temp);
144
+ same(path, created, false, mode);
145
+ assertDirectory();
146
+ },
147
+ dispose,
148
+ };
149
+ } catch (error) {
150
+ try { dispose(); } catch { /* preserve safety failure; never remove a substituted path */ }
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ /** Guard every CA entry before SQLite or PEM access. The directory and lock stay pinned. */
156
+ export function withLocalCaPublication<T>(dir: string, lockName: string, work: (files: LocalCaFiles) => T): T {
157
+ if (basename(lockName) !== lockName || [KEY, CERT, ".", ".."].includes(lockName)) throw unsafe();
158
+ mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
159
+ let created = false;
160
+ try { mkdirSync(dir, { mode: 0o700 }); created = true; }
161
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
162
+ // Publication-local only: a private DACL can be changed only by the owner,
163
+ // SYSTEM or Administrators. Every memo hit still checks lstat identity via same().
164
+ // bigint birthtimeNs adds discrimination for recycled SQLite sidecar file IDs; it is not a
165
+ // uniqueness guarantee (NTFS tunneling can restore creation times). Safety rests on the
166
+ // verified-private directory DACL. Creation time survives same-volume rename, so staged
167
+ // entries keep their memo.
168
+ const verified = new Map<string, AclLevel>();
169
+ const strength = { owner: 0, inherited: 1, private: 2 };
170
+ const checkAcl: AclCheck = Object.assign((path: string, stat: BigIntStats, level: AclLevel = "private") => {
171
+ if (!windowsSecretAclApplies()) return;
172
+ const identity = same(path, stat, stat.isDirectory());
173
+ const key = `${identity.dev}:${identity.ino}:${identity.birthtimeNs}`;
174
+ const prior = verified.get(key);
175
+ if (prior !== undefined && strength[prior] >= strength[level]) return;
176
+ assertLocalCaWindowsAcl(path, level);
177
+ same(path, stat, stat.isDirectory());
178
+ verified.set(key, level);
179
+ }, {
180
+ // An identity this publication unlinked or replaced is retired; a recycled id must be re-inspected.
181
+ forget(stat: BigIntStats): void { verified.delete(`${stat.dev}:${stat.ino}:${stat.birthtimeNs}`); },
182
+ });
183
+ const directory = lstatSync(dir, { bigint: true });
184
+ owned(directory, true);
185
+ const dirFd = windowsSecretAclApplies() ? null : openSync(dir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | NOFOLLOW);
186
+ let lockFd: number | undefined;
187
+ try {
188
+ if (dirFd !== null) {
189
+ const opened = fstatSync(dirFd, { bigint: true });
190
+ if (opened.dev !== directory.dev || opened.ino !== directory.ino) throw unsafe();
191
+ owned(opened, true);
192
+ } else {
193
+ let needsHardening = created;
194
+ // A successful mkdir created this directory; strict verification after narrowing
195
+ // still refuses an Administrators default owner before any CA access.
196
+ if (!created) {
197
+ try { checkAcl(dir, directory); }
198
+ catch { checkAcl(dir, directory, "inherited"); needsHardening = true; }
199
+ }
200
+ if (needsHardening) {
201
+ same(dir, directory, true);
202
+ hardenSecretDir(dir, { required: true });
203
+ same(dir, directory, true);
204
+ checkAcl(dir, directory);
205
+ }
206
+ }
207
+ const assertDirectory = (): void => { same(dir, directory, true); };
208
+ assertDirectory();
209
+ const lockPath = join(dir, lockName);
210
+ const assertEntries = (): void => {
211
+ assertDirectory();
212
+ inspect(join(dir, KEY), 0o600, checkAcl);
213
+ inspect(join(dir, CERT), 0o644, checkAcl);
214
+ inspect(lockPath, 0o600, checkAcl);
215
+ // SQLite creates sidecars itself, inheriting only the protected directory's
216
+ // current-user ACE. Inspect every grant and owner; an inherited DACL is valid here.
217
+ for (const suffix of ["-journal", "-wal", "-shm"]) inspect(`${lockPath}${suffix}`, 0o600, checkAcl, true);
218
+ assertDirectory();
219
+ };
220
+ assertEntries();
221
+ try {
222
+ lockFd = openSync(lockPath, constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK, 0o600);
223
+ protectNew(lockPath, lockFd, 0o600, checkAcl);
224
+ } catch (error) {
225
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
226
+ const existing = inspect(lockPath, 0o600, checkAcl);
227
+ if (!existing) throw unsafe();
228
+ lockFd = openSync(lockPath, constants.O_RDONLY | NOFOLLOW | NONBLOCK);
229
+ const opened = fstatSync(lockFd, { bigint: true });
230
+ owned(opened);
231
+ if (opened.dev !== existing.dev || opened.ino !== existing.ino) throw unsafe();
232
+ }
233
+ const lock = fstatSync(lockFd, { bigint: true });
234
+ assertEntries();
235
+ same(lockPath, lock);
236
+ return withClientLifecycleSync(() => {
237
+ same(lockPath, lock);
238
+ assertEntries();
239
+ const files: LocalCaFiles = {
240
+ readPair() {
241
+ assertEntries();
242
+ const cert = inspect(join(dir, CERT), 0o644, checkAcl);
243
+ const key = inspect(join(dir, KEY), 0o600, checkAcl);
244
+ if (!cert || !key) return null;
245
+ return { certPem: readPinned(join(dir, CERT), cert, 0o644, assertDirectory, checkAcl), keyPem: readPinned(join(dir, KEY), key, 0o600, assertDirectory, checkAcl) };
246
+ },
247
+ writePair(pair) {
248
+ assertEntries();
249
+ const key = stage(join(dir, KEY), pair.keyPem, 0o600, assertDirectory, checkAcl);
250
+ let cert: ReturnType<typeof stage> | undefined;
251
+ try {
252
+ cert = stage(join(dir, CERT), pair.certPem, 0o644, assertDirectory, checkAcl);
253
+ assertEntries();
254
+ key.publish();
255
+ cert.publish();
256
+ } finally { try { key.dispose(); } finally { cert?.dispose(); } }
257
+ },
258
+ };
259
+ const value = work(files);
260
+ same(lockPath, lock);
261
+ assertEntries();
262
+ return value;
263
+ }, { lockPath });
264
+ } finally { try { if (lockFd !== undefined) closeSync(lockFd); } finally { if (dirFd !== null) closeSync(dirFd); } }
265
+ }
@@ -0,0 +1,73 @@
1
+ /** Strict local-CA policy layered on the shared Windows ACL hardener. */
2
+ import { resolveTrustedWindowsPowerShellExe } from "../../lib/windows-elevation";
3
+ import { resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../../lib/windows-user-principal";
4
+ import type { IcaclsResult } from "../../lib/windows-secret-acl";
5
+
6
+ type AclRunner = (path: string, timeoutMs: number) => IcaclsResult;
7
+ const MAX_ACL_BYTES = 64 * 1024;
8
+ const SID = /^S-1-(?:\d+-)+\d+$/i;
9
+
10
+ function nativeAclRunner(path: string, timeoutMs: number): IcaclsResult {
11
+ // Encode the path as data, not PowerShell syntax. SID-form rules avoid localized account names.
12
+ // .NET only: Get-Acl/ConvertTo-Json/ForEach-Object are module-autoloaded cmdlets, and
13
+ // autoload resolves them through the per-profile module analysis cache. With a fresh or
14
+ // redirected LOCALAPPDATA that rebuild scans every PSModulePath module and can exceed the
15
+ // inspection budget (hosted Windows runners: 30 s timeouts during `ocx start`); it also lets
16
+ // a module earlier on PSModulePath shadow the cmdlet. SIDs, ints and booleans need no escaping;
17
+ // the JSON quote is [char]34 so the -Command argument carries no double quotes to re-parse.
18
+ const encodedPath = Buffer.from(path, "utf8").toString("base64");
19
+ const script = [
20
+ "$ErrorActionPreference='Stop'",
21
+ `$p=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPath}'))`,
22
+ "$s=[System.Security.Principal.SecurityIdentifier]",
23
+ "$q=[char]34",
24
+ "$c=[System.Security.AccessControl.AccessControlSections]'Owner,Access'",
25
+ "if([System.IO.Directory]::Exists($p)){$a=[System.Security.AccessControl.DirectorySecurity]::new($p,$c)}else{$a=[System.Security.AccessControl.FileSecurity]::new($p,$c)}",
26
+ "$r=foreach($x in $a.GetAccessRules($true,$true,$s)){'{'+$q+'sid'+$q+':'+$q+$x.IdentityReference.Value+$q+','+$q+'type'+$q+':'+[int]$x.AccessControlType+','+$q+'rights'+$q+':'+[long]$x.FileSystemRights+'}'}",
27
+ "$o='{'+$q+'owner'+$q+':'+$q+$a.GetOwner($s).Value+$q+','+$q+'protected'+$q+':'+$(if($a.AreAccessRulesProtected){'true'}else{'false'})+','+$q+'rules'+$q+':['+(@($r) -join ',')+']}'",
28
+ "[Console]::Out.Write($o)",
29
+ "[Console]::Out.Flush()",
30
+ ].join(";");
31
+ const result = Bun.spawnSync([resolveTrustedWindowsPowerShellExe(), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], {
32
+ stdin: "ignore", stdout: "pipe", stderr: "ignore", timeout: timeoutMs, windowsHide: true,
33
+ });
34
+ return { success: result.success, exitCode: result.exitCode, timedOut: result.exitedDueToTimeout ?? false, stdout: result.stdout.toString("utf8") };
35
+ }
36
+
37
+ let aclRunner: AclRunner = nativeAclRunner;
38
+ /** Native-result fault injection; production always uses the trusted bounded runner. */
39
+ export function setLocalCaWindowsAclRunnerForTests(next: AclRunner | null): void {
40
+ aclRunner = next ?? nativeAclRunner;
41
+ }
42
+
43
+ export function assertLocalCaWindowsAcl(path: string, inspection: "owner" | "private" | "inherited" = "private"): void {
44
+ try {
45
+ const deadline = Date.now() + WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS;
46
+ const current = resolveCurrentWindowsPrincipal(deadline - Date.now()).replace(/^\*/, "").toUpperCase();
47
+ if (!SID.test(current)) throw new Error("invalid effective SID");
48
+ const remaining = deadline - Date.now();
49
+ if (remaining <= 0) throw new Error("ACL inspection deadline");
50
+ const result = aclRunner(path, remaining);
51
+ if (!result.success || result.timedOut || result.exitCode !== 0 || Buffer.byteLength(result.stdout) > MAX_ACL_BYTES) throw new Error("ACL inspection failed");
52
+ const acl: unknown = JSON.parse(result.stdout.replace(/^\uFEFF/, ""));
53
+ if (!acl || typeof acl !== "object" || !("owner" in acl) || !("protected" in acl) || !("rules" in acl)
54
+ || typeof acl.owner !== "string" || acl.owner.toUpperCase() !== current
55
+ || typeof acl.protected !== "boolean" || !Array.isArray(acl.rules)) throw new Error("unsafe ACL owner or shape");
56
+ // Newly created empty entries can inherit broad grants. Verify the owner
57
+ // before hardening, then require the complete private policy before use.
58
+ if (inspection === "owner") return;
59
+ if (inspection === "private" && !acl.protected) throw new Error("unprotected DACL");
60
+ const allowed = new Set([current, "S-1-5-18", "S-1-5-32-544"]);
61
+ if (acl.rules.length === 0) throw new Error("empty DACL");
62
+ for (const rule of acl.rules) {
63
+ if (!rule || typeof rule !== "object" || typeof rule.sid !== "string" || !SID.test(rule.sid)
64
+ || ![0, 1].includes(rule.type) || !Number.isSafeInteger(rule.rights) || rule.rights < 0 || rule.rights > 0xffffffff) throw new Error("ambiguous ACL rule");
65
+ // Reject every unexpected nonzero Allow ACE, including inherited and tamper-only grants.
66
+ // Deny ACEs confer no access. SYSTEM and built-in Administrators are the only exceptions.
67
+ if (rule.type === 0 && rule.rights !== 0 && !allowed.has(rule.sid.toUpperCase())) throw new Error("unexpected ACL principal");
68
+ }
69
+ } catch {
70
+ // Never expose path, raw ACL output, account names or native diagnostic text.
71
+ throw Object.assign(new Error("Local CA Windows owner/ACL inspection failed"), { code: "local_ca_path_unsafe" });
72
+ }
73
+ }
@@ -1,7 +1,6 @@
1
1
  import { createHash, generateKeyPairSync, createPrivateKey, createPublicKey, sign, X509Certificate, type KeyObject } from "node:crypto";
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
- import { withClientLifecycleSync } from "../../client/lifecycle-lock";
3
+ import { withLocalCaPublication, type LocalCaFiles } from "./local-ca-files";
5
4
 
6
5
  /**
7
6
  * Local certificate authority for the Claude intercept listener.
@@ -307,7 +306,6 @@ export function issueLocalInterceptLeaf(ca: LocalInterceptCa, hosts: readonly st
307
306
 
308
307
  export const CLAUDE_INTERCEPT_STATE_DIR = "claude-intercept";
309
308
  export const CLAUDE_INTERCEPT_CA_CERT_FILE = "ca.pem";
310
- const CA_KEY_FILE = "ca.key";
311
309
 
312
310
  export function claudeInterceptStateDir(configDir: string): string {
313
311
  return join(configDir, CLAUDE_INTERCEPT_STATE_DIR);
@@ -318,50 +316,40 @@ export function claudeInterceptCaCertPath(configDir: string): string {
318
316
  return join(claudeInterceptStateDir(configDir), CLAUDE_INTERCEPT_CA_CERT_FILE);
319
317
  }
320
318
 
321
- function writeFileAtomic(path: string, contents: string, mode: number): void {
322
- const tmp = `${path}.${process.pid}.tmp`;
323
- writeFileSync(tmp, contents, { mode });
324
- try { chmodSync(tmp, mode); } catch { /* best-effort on platforms without POSIX modes */ }
325
- renameSync(tmp, path);
326
- }
327
-
328
- function loadPersistedCa(dir: string, accept?: (cert: X509Certificate) => boolean): LocalInterceptCa | null {
329
- const certPath = join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE);
330
- const keyPath = join(dir, CA_KEY_FILE);
331
- if (!existsSync(certPath) || !existsSync(keyPath)) return null;
319
+ function loadPersistedCa(files: LocalCaFiles, accept?: (cert: X509Certificate) => boolean): LocalInterceptCa | null {
320
+ // Filesystem safety and access errors must escape; only benign PEM corruption regenerates.
321
+ const pair = files.readPair();
322
+ if (!pair) return null;
323
+ const { certPem, keyPem } = pair;
332
324
  try {
333
- const certPem = readFileSync(certPath, "utf8");
334
- const keyPem = readFileSync(keyPath, "utf8");
335
325
  const privateKey = createPrivateKey(keyPem);
336
326
  const publicKey = createPublicKey(keyPem);
337
327
  const certificate = new X509Certificate(certPem);
338
328
  if (!certificate.ca || !certificate.checkPrivateKey(privateKey) || !certificate.verify(publicKey)
339
329
  || (accept && !accept(certificate))) return null;
340
330
  return { certPem, keyPem, publicKey, privateKey };
341
- } catch { // no-excuse-ok: catch -- an unreadable or corrupt authority is regenerated below.
331
+ } catch { // no-excuse-ok: catch -- malformed PEM in validated owner-controlled files is regenerated below.
342
332
  return null;
343
333
  }
344
334
  }
345
335
 
346
- /** Persist an authority under its own lease, replacing unreadable or rejected pairs. */
336
+ /** Persist an authority under its own lease, replacing corrupt or rejected safe pairs. */
347
337
  export function ensurePersistedAuthority(
348
338
  dir: string,
349
339
  options: AuthorityOptions,
350
340
  lockName = "ca-publication.sqlite",
351
341
  accept?: (cert: X509Certificate) => boolean,
352
342
  ): LocalInterceptCa {
353
- mkdirSync(dir, { recursive: true, mode: 0o700 });
354
343
  // A separate SQLite namespace binds exclusion to the explicit CA directory.
355
344
  // The OS releases it on crash; a contending caller fails before touching either
356
345
  // PEM. Readers also take the lease so they cannot observe half a publication.
357
- return withClientLifecycleSync(() => {
358
- const existing = loadPersistedCa(dir, accept);
346
+ return withLocalCaPublication(dir, lockName, files => {
347
+ const existing = loadPersistedCa(files, accept);
359
348
  if (existing) return existing;
360
349
  const ca = createCertificateAuthority(options);
361
- writeFileAtomic(join(dir, CA_KEY_FILE), ca.keyPem, 0o600);
362
- writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644);
350
+ files.writePair(ca);
363
351
  return ca;
364
- }, { lockPath: join(dir, lockName) });
352
+ });
365
353
  }
366
354
 
367
355
  /** Preserve the original intercept CA path, name, permissions and extension set. */
@@ -0,0 +1,106 @@
1
+ import { X509Certificate } from "node:crypto";
2
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, rmSync, writeFileSync, type Stats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ decodePickerCaCredential, openPickerCaCredential, pickerCaConfigId, readPickerCaCredential,
6
+ writePickerCaCredential, type PickerCaStore, type StoredPickerCa,
7
+ } from "./picker-ca-store";
8
+
9
+ const MAX_PUBLIC_STATE_BYTES = 64 * 1024;
10
+ interface AuthorityMetadata { schema: 1; configId: string; fingerprint: string }
11
+ interface Initialization extends AuthorityMetadata { predecessor: string | null }
12
+
13
+ export function canonicalPickerConfigDir(configDir: string): string {
14
+ const canonical = realpathSync(configDir);
15
+ const stat = lstatSync(canonical);
16
+ if (!stat.isDirectory() || (process.platform !== "win32" && stat.uid !== process.getuid!())) {
17
+ throw new Error("picker_ca_config_unsafe");
18
+ }
19
+ return canonical;
20
+ }
21
+
22
+ /** Public records still cannot follow symlinks or accept oversized/malformed recovery data. */
23
+ function readState(path: string, configId: string, initialization: boolean): Initialization | AuthorityMetadata | null {
24
+ let expected: Stats;
25
+ try { expected = lstatSync(path); }
26
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
27
+ if (expected.isSymbolicLink()) throw new Error("picker_ca_metadata_unsafe");
28
+ let fd: number;
29
+ try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
30
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw new Error("picker_ca_metadata_unsafe"); }
31
+ try {
32
+ const stat = fstatSync(fd);
33
+ if (stat.dev !== expected.dev || stat.ino !== expected.ino) throw new Error("picker_ca_metadata_unsafe");
34
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_PUBLIC_STATE_BYTES
35
+ || (process.platform !== "win32" && stat.uid !== process.getuid!())) throw new Error();
36
+ const bytes = Buffer.alloc(MAX_PUBLIC_STATE_BYTES + 1);
37
+ let used = 0;
38
+ while (used < bytes.length) {
39
+ const count = readSync(fd, bytes, used, bytes.length - used, null);
40
+ if (count === 0) break;
41
+ used += count;
42
+ }
43
+ if (used > MAX_PUBLIC_STATE_BYTES) throw new Error();
44
+ const raw = bytes.subarray(0, used).toString("utf8");
45
+ const value = JSON.parse(raw);
46
+ const keys = initialization ? "configId,fingerprint,predecessor,schema" : "configId,fingerprint,schema";
47
+ if (value === null || typeof value !== "object" || Array.isArray(value)
48
+ || Object.keys(value).sort().join(",") !== keys || value.schema !== 1 || value.configId !== configId
49
+ || typeof value.fingerprint !== "string" || !/^[A-F0-9]{64}$/.test(value.fingerprint)
50
+ || (initialization && value.predecessor !== null && typeof value.predecessor !== "string")) throw new Error();
51
+ if (initialization && value.predecessor !== null
52
+ && new X509Certificate(value.predecessor).toString() !== value.predecessor) throw new Error();
53
+ return value;
54
+ } catch (error) {
55
+ if (error instanceof Error && error.message === "picker_ca_metadata_unsafe") throw error;
56
+ throw new Error("picker_ca_metadata_invalid");
57
+ }
58
+ finally { closeSync(fd); }
59
+ }
60
+
61
+ export interface PersistentPickerCaOptions {
62
+ configDir: string;
63
+ stateDir: string;
64
+ store?: PickerCaStore;
65
+ acceptsAuthority: (pem: string) => boolean;
66
+ create: () => StoredPickerCa;
67
+ /** Refuses a different live predecessor BEFORE the journal or credential can be written. */
68
+ predecessor: (ca: StoredPickerCa) => string | null;
69
+ publish: (ca: StoredPickerCa, predecessor: string | null) => void;
70
+ publishMetadata: (path: string, value: string) => void;
71
+ }
72
+
73
+ /** Caller holds the canonical picker CA lease throughout the store/publication transaction. */
74
+ export function ensurePersistentPickerCa(options: PersistentPickerCaOptions): StoredPickerCa {
75
+ const configId = pickerCaConfigId(options.configDir);
76
+ const metadataPath = join(options.stateDir, "authority.json");
77
+ const journalPath = join(options.stateDir, "authority-init.json");
78
+ const metadata = readState(metadataPath, configId, false);
79
+ let journal = readState(journalPath, configId, true) as Initialization | null;
80
+ const entry = openPickerCaCredential(configId, options.store);
81
+ const raw = readPickerCaCredential(entry);
82
+ let ca: StoredPickerCa;
83
+ if (raw === null) {
84
+ if (metadata || journal) throw new Error("picker_ca_store_missing");
85
+ ca = options.create();
86
+ const predecessor = options.predecessor(ca);
87
+ journal = { schema: 1, configId, fingerprint: ca.fingerprint, predecessor };
88
+ // Exclusive evidence is durable before ANY credential mutation. Never erase it on failure.
89
+ writeFileSync(journalPath, JSON.stringify(journal) + "\n", { flag: "wx", mode: 0o600 });
90
+ writePickerCaCredential(entry, configId, ca);
91
+ } else {
92
+ ca = decodePickerCaCredential(raw, configId, options.acceptsAuthority);
93
+ if ((!metadata && !journal) || (metadata && metadata.fingerprint !== ca.fingerprint)
94
+ || (journal && journal.fingerprint !== ca.fingerprint)) throw new Error("picker_ca_metadata_mismatch");
95
+ const predecessor = options.predecessor(ca);
96
+ if (journal) {
97
+ // A crash may have published the new certificate already, but no different predecessor
98
+ // may be adopted under an existing journal. Public evidence must be exact and key-free.
99
+ if (predecessor !== null && predecessor !== journal.predecessor) throw new Error("picker_ca_metadata_mismatch");
100
+ } else if (predecessor !== null) throw new Error("picker_ca_metadata_mismatch");
101
+ }
102
+ options.publishMetadata(metadataPath, JSON.stringify({ schema: 1, configId, fingerprint: ca.fingerprint }) + "\n");
103
+ options.publish(ca, journal?.predecessor ?? null);
104
+ if (journal) rmSync(journalPath); // Last: store, metadata, certificate, owner, and cleanup evidence now exist.
105
+ return ca;
106
+ }
@@ -0,0 +1,16 @@
1
+ import { ensurePickerCa, type PickerCa } from "./picker-ca";
2
+ import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup";
3
+ import type { PickerCaStore } from "./picker-ca-store";
4
+ import type { SecurityRunner } from "./picker-trust";
5
+
6
+ /** Internal activation seam: finish legacy cleanup before a persistent issuer is usable. */
7
+ export async function preparePersistentPickerAuthority(options: {
8
+ configDir: string; store?: PickerCaStore; security?: SecurityRunner; platform: NodeJS.Platform;
9
+ }): Promise<PickerCa> {
10
+ if (options.platform !== "darwin") throw new Error("picker_ca_unsupported_platform");
11
+ const drain = () => drainPendingPickerCaUntrust(options.configDir, options.security, options.platform);
12
+ if (!await drain()) throw new Error("picker_ca_pending_untrust");
13
+ const ca = ensurePickerCa(options.configDir, { rotation: "startup", persistent: true, store: options.store });
14
+ if (!await drain()) throw new Error("picker_ca_pending_untrust");
15
+ return ca;
16
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash, createPrivateKey, createPublicKey, X509Certificate } from "node:crypto";
2
+ import { loadKeyringBinding } from "../../lib/keyring-native";
3
+ import type { LocalInterceptCa } from "./local-ca";
4
+
5
+ export const PICKER_CA_STORE_SERVICE = "opencodex.claude-desktop-picker.ca.v1";
6
+ const MAX_PAYLOAD_BYTES = 16 * 1024;
7
+ export interface PickerCaCredential {
8
+ getPassword(): string | null;
9
+ setPassword(value: string): void;
10
+ }
11
+ export type PickerCaStore = (service: string, account: string) => PickerCaCredential;
12
+ export interface StoredPickerCa extends LocalInterceptCa { fingerprint: string }
13
+
14
+ export function pickerCaConfigId(canonicalConfigDir: string): string {
15
+ return createHash("sha256").update(canonicalConfigDir).digest("hex");
16
+ }
17
+
18
+ function nativeStore(service: string, account: string): PickerCaCredential {
19
+ const { Entry } = loadKeyringBinding();
20
+ // The shared loader is deliberately binding-agnostic; this is the sync Entry contract.
21
+ return new Entry(service, account) as PickerCaCredential;
22
+ }
23
+
24
+ export function openPickerCaCredential(configId: string, store: PickerCaStore = nativeStore): PickerCaCredential {
25
+ try { return store(PICKER_CA_STORE_SERVICE, configId); }
26
+ catch { throw new Error("picker_ca_store_unavailable"); }
27
+ }
28
+
29
+ export function readPickerCaCredential(entry: PickerCaCredential): string | null {
30
+ try {
31
+ const value = entry.getPassword();
32
+ if (value === null) return null;
33
+ if (typeof value !== "string") throw new Error();
34
+ return value;
35
+ } catch { throw new Error("picker_ca_store_unavailable"); }
36
+ }
37
+
38
+ /** OS credential contents are an untrusted, bounded, exact-shape boundary. */
39
+ export function decodePickerCaCredential(
40
+ raw: string, configId: string, acceptsAuthority: (pem: string) => boolean,
41
+ ): StoredPickerCa {
42
+ try {
43
+ if (Buffer.byteLength(raw) > MAX_PAYLOAD_BYTES) throw new Error();
44
+ const value = JSON.parse(raw);
45
+ if (value === null || typeof value !== "object" || Array.isArray(value)
46
+ || Object.keys(value).sort().join(",") !== "certPem,configId,fingerprint,keyPem,schema"
47
+ || value.schema !== 1 || value.configId !== configId
48
+ || typeof value.certPem !== "string" || typeof value.keyPem !== "string"
49
+ || typeof value.fingerprint !== "string" || !/^[A-F0-9]{64}$/.test(value.fingerprint)) throw new Error();
50
+ const cert = new X509Certificate(value.certPem);
51
+ const privateKey = createPrivateKey(value.keyPem);
52
+ const publicKey = createPublicKey(value.keyPem);
53
+ const now = Date.now();
54
+ if (cert.toString() !== value.certPem || !acceptsAuthority(value.certPem)
55
+ || !cert.ca || !cert.checkPrivateKey(privateKey) || !cert.verify(publicKey)
56
+ || privateKey.asymmetricKeyType !== "ec" || privateKey.asymmetricKeyDetails?.namedCurve !== "prime256v1"
57
+ || privateKey.export({ type: "pkcs8", format: "pem" }) !== value.keyPem
58
+ || !(Date.parse(cert.validFrom) <= now && now < Date.parse(cert.validTo))
59
+ || createHash("sha256").update(cert.raw).digest("hex").toUpperCase() !== value.fingerprint) throw new Error();
60
+ return { certPem: value.certPem, keyPem: value.keyPem, fingerprint: value.fingerprint, privateKey, publicKey };
61
+ } catch { throw new Error("picker_ca_store_invalid"); }
62
+ }
63
+
64
+ export function writePickerCaCredential(entry: PickerCaCredential, configId: string, ca: StoredPickerCa): void {
65
+ const raw = JSON.stringify({ schema: 1, configId, fingerprint: ca.fingerprint, certPem: ca.certPem, keyPem: ca.keyPem });
66
+ if (Buffer.byteLength(raw) > MAX_PAYLOAD_BYTES) throw new Error("picker_ca_store_invalid");
67
+ try { entry.setPassword(raw); }
68
+ catch { throw new Error("picker_ca_store_unavailable"); }
69
+ if (readPickerCaCredential(entry) !== raw) throw new Error("picker_ca_store_readback_failed");
70
+ }