@bitkyc08/opencodex 2.78.0 → 2.79.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 (71) hide show
  1. package/gui/dist/assets/App-BOUa-ek3.js +54 -0
  2. package/gui/dist/assets/App-CSJvmjNs.css +1 -0
  3. package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-DUtzB4zF.js} +1 -1
  4. package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
  5. package/gui/dist/assets/index-DwHNDF4s.js +94 -0
  6. package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-BuL4WIBo.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +3 -2
  9. package/src/adapters/devin.ts +22 -1
  10. package/src/claude/desktop-picker.ts +11 -9
  11. package/src/claude/inbound-content-options.ts +29 -2
  12. package/src/claude/intercept/local-ca-files.ts +265 -0
  13. package/src/claude/intercept/local-ca-windows.ts +73 -0
  14. package/src/claude/intercept/local-ca.ts +12 -24
  15. package/src/claude/intercept/picker-ca-persistence.ts +106 -0
  16. package/src/claude/intercept/picker-ca-startup.ts +16 -0
  17. package/src/claude/intercept/picker-ca-store.ts +70 -0
  18. package/src/claude/intercept/picker-ca.ts +43 -9
  19. package/src/claude/intercept/picker-runtime.ts +14 -4
  20. package/src/claude/intercept/runtime.ts +32 -23
  21. package/src/cli/ensure-readiness.ts +48 -0
  22. package/src/cli/gui-pair-client.ts +15 -7
  23. package/src/cli/gui.ts +4 -1
  24. package/src/cli/hub.ts +1 -0
  25. package/src/cli/index.ts +10 -11
  26. package/src/cli/status.ts +2 -2
  27. package/src/cli/update-restart-child.ts +4 -1
  28. package/src/codex/account-credit-use.ts +8 -2
  29. package/src/codex/auth-api/account-pause-group.ts +113 -0
  30. package/src/codex/auth-api/routes.ts +18 -12
  31. package/src/codex/auth-context.ts +45 -3
  32. package/src/codex/autostart-health.ts +10 -0
  33. package/src/codex/pool-credit-policy.ts +67 -0
  34. package/src/generated/compatibility-version.json +106 -46
  35. package/src/lib/gui-pair-capability.ts +1 -1
  36. package/src/lib/gui-pair-intent.ts +170 -0
  37. package/src/lib/windows-owner-acl.ts +143 -0
  38. package/src/providers/openai-sidecar-credit.ts +26 -0
  39. package/src/providers/openai-sidecar.ts +35 -0
  40. package/src/server/claude-messages.ts +1 -0
  41. package/src/server/gui-pair-delivery.ts +23 -0
  42. package/src/server/index/serve-options.ts +5 -8
  43. package/src/server/live-native-calls.ts +94 -0
  44. package/src/server/live.ts +28 -5
  45. package/src/server/local-management-read-client.ts +15 -11
  46. package/src/server/management/oauth-account-routes.ts +15 -0
  47. package/src/server/management/route-registry.ts +1 -0
  48. package/src/server/management/shared.ts +9 -3
  49. package/src/server/messages-native-scope.ts +57 -0
  50. package/src/server/messages-native.ts +9 -0
  51. package/src/server/responses/adapter-dispatch.ts +31 -2
  52. package/src/server/responses/compact.ts +17 -2
  53. package/src/server/responses/core-auth.ts +18 -1
  54. package/src/server/responses/core-codex-account.ts +18 -8
  55. package/src/server/responses/non-replayable-error.ts +48 -0
  56. package/src/server/responses/passthrough-delivery.ts +5 -1
  57. package/src/server/responses/passthrough-dispatch.ts +21 -19
  58. package/src/server/responses/request-prepare.ts +11 -1
  59. package/src/server/responses/sidecar-execution.ts +1 -0
  60. package/src/server/search.ts +4 -0
  61. package/src/server/startup-health-cache.ts +2 -6
  62. package/src/vision/describe.ts +17 -10
  63. package/src/vision/index.ts +1 -0
  64. package/src/web-search/executor.ts +17 -10
  65. package/src/web-search/format-result.ts +304 -20
  66. package/src/web-search/loop.ts +2 -1
  67. package/src/web-search/passthrough-bridge.ts +1 -0
  68. package/src/web-search/run-turn-loop.ts +1 -1
  69. package/gui/dist/assets/App-BtfG5id-.js +0 -54
  70. package/gui/dist/assets/App-lCIslZ1L.css +0 -1
  71. package/gui/dist/assets/index-Btc3Ogw4.js +0 -94
@@ -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
+ }
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID, X509Certificate } from "node:crypto";
2
2
  import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { canonicalPickerConfigDir, ensurePersistentPickerCa } from "./picker-ca-persistence";
5
+ import type { PickerCaStore } from "./picker-ca-store";
4
6
  import { withClientLifecycleSync } from "../../client/lifecycle-lock";
5
7
  import {
6
8
  ALL_IP_ADDRESS_BASES,
@@ -405,29 +407,58 @@ export function acknowledgePendingPickerCaUntrust(
405
407
  * unconditional so a later failed rotation can never leave that key behind.
406
408
  */
407
409
  export function discardPickerCaKey(configDir: string): void {
410
+ try {
411
+ const stat = lstatSync(pickerStateDir(configDir));
412
+ if (!stat.isDirectory() || (process.platform !== "win32" && stat.uid !== process.getuid!())) {
413
+ throw new Error("picker_ca_directory_unsafe");
414
+ }
415
+ } catch (error) {
416
+ if (error !== null && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") return;
417
+ throw error;
418
+ }
408
419
  rmSync(join(pickerStateDir(configDir), "ca.key"), { force: true });
409
420
  }
410
421
 
411
- export function ensurePickerCa(configDir: string, options: { rotation?: "startup" } = {}): PickerCa {
422
+ export function ensurePickerCa(configDir: string, options: { rotation?: "startup"; persistent?: boolean; store?: PickerCaStore } = {}): PickerCa {
423
+ mkdirSync(configDir, { recursive: true, mode: 0o700 });
424
+ configDir = canonicalPickerConfigDir(configDir);
412
425
  const dir = pickerStateDir(configDir);
413
- // The signing key must never survive this process: another process under the same user could
414
- // otherwise steal it and later take over the predictable loopback proxy. Drop a key left (or
415
- // restored) by an older release on every call, including cache hits.
426
+ // Persistent startup stores the signing key in the OS credential store; plain calls keep an
427
+ // in-process authority. Both paths remove legacy plaintext ca.key files, including cache hits.
416
428
  discardPickerCaKey(configDir);
417
429
  const cached = processAuthorities.get(dir);
418
- const pickerCa = cached ?? (() => {
430
+ const create = () => cached ?? (() => {
419
431
  const ca = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] });
420
432
  return { ...ca, fingerprint: pickerCaFingerprints(ca.certPem).sha256 };
421
433
  })();
422
434
  mkdirSync(dir, { recursive: true, mode: 0o700 });
423
- lockedPickerCa(configDir, () => {
435
+ const pickerCa = lockedPickerCa(configDir, () => {
424
436
  if (readPendingPickerCaUntrust(configDir)) throw new Error("picker_ca_pending_untrust");
437
+ if (options.persistent) {
438
+ return ensurePersistentPickerCa({
439
+ configDir, stateDir: dir, store: options.store, acceptsAuthority: acceptsPickerAuthority, create,
440
+ predecessor: ca => {
441
+ const published = publishedPickerCa(configDir);
442
+ if (published === ca.certPem || published === null) return null;
443
+ if (livePublishedOwner(configDir, published)) throw new Error("picker_ca_live_owner");
444
+ const outgoing = publicCertificate(published);
445
+ if (outgoing && options.rotation !== "startup") throw new Error("picker_ca_rotation_requires_startup");
446
+ return outgoing?.certPem ?? null;
447
+ },
448
+ publishMetadata: publishPem,
449
+ publish: (ca, predecessor) => {
450
+ if (predecessor) writePendingPickerCaUntrust(configDir, publicCertificate(predecessor)!);
451
+ publishAuthority(configDir, ca);
452
+ },
453
+ });
454
+ }
455
+ const pickerCa = create();
425
456
  const published = publishedPickerCa(configDir);
426
457
  if (published === pickerCa.certPem) {
427
458
  // The signing key is still in this process. A missing/stale owner file must not let a
428
459
  // second process rotate this live authority after an otherwise harmless cached ensure.
429
460
  if (!currentProcessOwnsPublishedCa(configDir, pickerCa)) publishOwner(configDir, pickerCa);
430
- return;
461
+ return pickerCa;
431
462
  }
432
463
  if (published !== null && livePublishedOwner(configDir, published)) {
433
464
  throw new Error("picker_ca_live_owner");
@@ -438,14 +469,17 @@ export function ensurePickerCa(configDir: string, options: { rotation?: "startup
438
469
  writePendingPickerCaUntrust(configDir, outgoing);
439
470
  }
440
471
  publishAuthority(configDir, pickerCa);
472
+ return pickerCa;
441
473
  });
442
- if (!cached) processAuthorities.set(dir, pickerCa);
474
+ if (!cached || options.persistent) processAuthorities.set(dir, pickerCa);
443
475
  return pickerCa;
444
476
  }
445
477
 
446
478
  /** Fingerprint of this process's authority only — null until ensurePickerCa has run. */
447
479
  export function publishedPickerCaSha256(configDir: string): string | null {
448
- const cached = processAuthorities.get(pickerStateDir(configDir));
480
+ let canonical: string;
481
+ try { canonical = canonicalPickerConfigDir(configDir); } catch { return null; }
482
+ const cached = processAuthorities.get(pickerStateDir(canonical));
449
483
  return cached ? pickerCaFingerprints(cached.certPem).sha256 : null;
450
484
  }
451
485
 
@@ -19,6 +19,8 @@ import type { ClaudeDesktopMode } from "../desktop-first-party";
19
19
  import type { TunnelDecision } from "./connect-proxy";
20
20
  import type { PemKeyPair } from "./local-ca";
21
21
  import { PICKER_HOST, ensurePickerCa, issuePickerLeaf, pickerCaFingerprints, pickerLeafCertPath, pickerStateDir, type PickerCa } from "./picker-ca";
22
+ import { preparePersistentPickerAuthority } from "./picker-ca-startup";
23
+ import type { PickerCaStore } from "./picker-ca-store";
22
24
  import { startPickerListener, type PickerListenerHandle } from "./picker-listener";
23
25
  import { createPickerModelSnapshot, type PickerModelSnapshot, type PickerRouteInput } from "./picker-models";
24
26
  import { inspectPickerTrust, type PickerTrustState, type SecurityRunner } from "./picker-trust";
@@ -81,6 +83,8 @@ export interface CreatePickerRuntimeOptions {
81
83
  isBusy?: () => boolean;
82
84
  configDir: string;
83
85
  loadRoutes: () => Promise<PickerRouteInput>;
86
+ /** Production opt-in; pure/direct runtime callers remain ephemeral. */
87
+ persistentAuthority?: { store?: PickerCaStore };
84
88
  security?: SecurityRunner;
85
89
  platform?: NodeJS.Platform;
86
90
  now?: () => number;
@@ -161,9 +165,11 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
161
165
  if (pending) void pending.then(handle => handle.close()).catch(() => {});
162
166
  }
163
167
 
164
- function ensureMaterial(): { ca: PickerCa; leaf: PemKeyPair; sha1: string } {
168
+ async function ensureMaterial(): Promise<{ ca: PickerCa; leaf: PemKeyPair; sha1: string }> {
165
169
  if (!ca || !leaf || !caSha1) {
166
- ca = ensurePickerCa(options.configDir);
170
+ ca = options.persistentAuthority
171
+ ? await preparePersistentPickerAuthority({ configDir: options.configDir, store: options.persistentAuthority.store, security: options.security, platform })
172
+ : ensurePickerCa(options.configDir);
167
173
  leaf = issuePickerLeaf(ca, options.configDir);
168
174
  caSha1 = pickerCaFingerprints(ca.certPem).sha1;
169
175
  }
@@ -176,7 +182,7 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
176
182
  }
177
183
 
178
184
  async function inspectTrust(force: boolean): Promise<PickerTrustState> {
179
- const { sha1 } = ensureMaterial();
185
+ const { sha1 } = await ensureMaterial();
180
186
  if (!force && trustFor === sha1 && now() - trustCheckedAt < trustTtlMs) return trust;
181
187
  trust = await inspectPickerTrust(pickerLeafCertPath(options.configDir), sha1, options.security, platform);
182
188
  trustFor = sha1;
@@ -186,7 +192,8 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
186
192
 
187
193
  /** CA, leaf, snapshot and listener. Resolves false when a disarm overtook it. */
188
194
  async function startPieces(gen: number): Promise<boolean> {
189
- const material = ensureMaterial();
195
+ const material = await ensureMaterial();
196
+ if (gen !== generation || latched || stopped) return false;
190
197
  ensureSnapshot().refreshIfStale(PICKER_MODELS_MAX_AGE_MS);
191
198
  if (!listenerPromise) {
192
199
  const current = startListener({
@@ -266,6 +273,9 @@ export function createPickerRuntime(options: CreatePickerRuntimeOptions): Picker
266
273
  });
267
274
  },
268
275
  async refreshTrust() {
276
+ // A dormant production picker has no authority to inspect. Status/off must not initialize
277
+ // credentials; an already initialized issuer still gets authoritative OS trust inspection.
278
+ if (options.persistentAuthority && ca === null) return trust;
269
279
  return inspectTrust(true);
270
280
  },
271
281
  async refresh() {
@@ -8,10 +8,11 @@ import { createCliCatalogProvider } from "./cli-picker";
8
8
  import { CLAUDE_INTERCEPT_HOSTS, isBrowserConnect, startConnectProxy, type ConnectProxyHandle } from "./connect-proxy";
9
9
  import { startClaudeInterceptListener } from "./listener";
10
10
  import { claudeInterceptCaCertPath, ensureLocalInterceptCaForStartup, issueLocalInterceptLeaf } from "./local-ca";
11
- import { discardPickerCaKey, ensurePickerCa } from "./picker-ca";
12
- import { drainPendingPickerCaUntrust } from "./picker-ca-cleanup";
11
+ import { discardPickerCaKey } from "./picker-ca";
12
+ import { preparePersistentPickerAuthority } from "./picker-ca-startup";
13
+ import type { PickerCaStore } from "./picker-ca-store";
13
14
  import type { PickerRouteInput } from "./picker-models";
14
- import { createPickerRuntime, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
15
+ import { createPickerRuntime, pickerDesired, type CreatePickerRuntimeOptions, type PickerRuntime } from "./picker-runtime";
15
16
  import type { SecurityRunner } from "./picker-trust";
16
17
  import { ensureClaudeInterceptProxyToken, readClaudeInterceptProxyToken } from "./proxy-auth";
17
18
  import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./settings";
@@ -31,6 +32,15 @@ import { buildClaudeInterceptEnv, migrateClaudeInterceptSettings } from "./setti
31
32
  * tunnels may be terminated by the picker runtime (src/claude/intercept/picker-runtime.ts).
32
33
  */
33
34
 
35
+ const PICKER_AUTHORITY_ERROR_CODES = new Set([
36
+ "picker_ca_store_unavailable", "picker_ca_store_missing", "picker_ca_store_invalid",
37
+ "picker_ca_store_readback_failed", "picker_ca_metadata_mismatch", "picker_ca_metadata_unsafe",
38
+ "picker_ca_metadata_invalid", "picker_ca_live_owner", "picker_ca_pending_untrust",
39
+ "picker_ca_pending_untrust_invalid", "picker_ca_pending_untrust_unsafe", "picker_ca_pending_untrust_changed",
40
+ "picker_ca_config_unsafe", "picker_ca_directory_unsafe", "picker_ca_rotation_requires_startup",
41
+ "picker_ca_unsupported_platform",
42
+ ]);
43
+
34
44
  export const CLAUDE_INTERCEPT_PORT_OFFSET = 100;
35
45
 
36
46
  export function claudeInterceptEnabled(config: Pick<OcxConfig, "claudeCode" | "runtimeRole">): boolean {
@@ -135,6 +145,7 @@ export interface StartClaudeInterceptOptions<T> {
135
145
  /** Test seam: bind real CONNECT handlers on kernel-assigned ports without probe-and-release races. */
136
146
  startProxy?: typeof startConnectProxy;
137
147
  /** Test seams: the macOS `security` runner and platform for the picker runtime and controller. */
148
+ pickerCaStore?: PickerCaStore;
138
149
  pickerSecurity?: SecurityRunner;
139
150
  pickerPlatform?: NodeJS.Platform;
140
151
  }
@@ -200,10 +211,8 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
200
211
  let pickerReason: ClaudeInterceptState["pickerReason"] = null;
201
212
  let pickerFailurePort: number | undefined;
202
213
  try {
203
- if (options.loadPickerRoutes) {
204
- // A picker authority is process-scoped, and older releases persisted an exportable ca.key
205
- // next to the published certificate. Drop that key before anything else: a cleanup failure
206
- // below must never leave a signing key on disk that outlives this process.
214
+ if (options.loadPickerRoutes && (options.pickerPlatform ?? process.platform) === "darwin") {
215
+ // Remove any legacy plaintext key before inspecting or activating picker state.
207
216
  discardPickerCaKey(configDir);
208
217
  const { inspectDesktopPickerProfile } = await import("../desktop-picker-profile");
209
218
  // The applied profile row is durable evidence of the user's picker selection. Rotation keeps
@@ -217,22 +226,23 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
217
226
  const pickerProfileApplied = pickerProfile.kind === "applied";
218
227
  let pickerBlocked = false;
219
228
  try {
220
- // A prior process may have died after publishing the journal but before cleanup. Finish
221
- // that entry before rotation can replace it, then drain the newly queued predecessor.
222
- const drain = () => drainPendingPickerCaUntrust(configDir, options.pickerSecurity, options.pickerPlatform);
223
- if (!await drain()) pickerBlocked = true;
224
- if (!pickerBlocked) {
225
- ensurePickerCa(configDir, { rotation: "startup" });
226
- if (!await drain()) pickerBlocked = true;
229
+ const { observeClaudeDesktopMode, resolveClaudeDesktopMode } = await import("../desktop-first-party");
230
+ const fresh = options.config;
231
+ const platform = options.pickerPlatform ?? process.platform;
232
+ if (pickerProfileApplied || pickerDesired(fresh, resolveClaudeDesktopMode(fresh, observeClaudeDesktopMode(fresh)), platform)) {
233
+ await preparePersistentPickerAuthority({ configDir, store: options.pickerCaStore, security: options.pickerSecurity, platform });
227
234
  }
228
235
  } catch (error) {
229
236
  pickerBlocked = true;
230
237
  pickerReason = "failed";
231
- console.warn(`⚠ Claude Desktop picker CA cleanup deferred: ${error instanceof Error ? error.message : String(error)}`);
238
+ // Native and filesystem errors may include private contents or paths. Emit only an exact
239
+ // application-owned code; arbitrary diagnostic text never crosses this boundary.
240
+ const code = error instanceof Error && PICKER_AUTHORITY_ERROR_CODES.has(error.message) ? error.message : null;
241
+ console.warn(code === "picker_ca_pending_untrust"
242
+ ? "⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted"
243
+ : `⚠ Claude Desktop picker disabled: picker authority unavailable${code ? ` (${code})` : ""}`);
232
244
  }
233
245
  if (pickerBlocked) {
234
- pickerReason = "failed";
235
- console.warn("⚠ Claude Desktop picker disabled: the previous certificate could not be untrusted");
236
246
  if (pickerProfile.kind === "applied") {
237
247
  // Keep Desktop's actual pinned egress alive without ever constructing a TLS terminator.
238
248
  const port = Number(new URL(pickerProfile.proxyUrl).port);
@@ -253,6 +263,7 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
253
263
  picker = pickerBlocked ? null : (options.createPicker ?? createPickerRuntime)({
254
264
  config: options.config,
255
265
  configDir,
266
+ persistentAuthority: { store: options.pickerCaStore },
256
267
  loadRoutes: options.loadPickerRoutes,
257
268
  // The controller's lock: while it is held, periodic refreshes never arm.
258
269
  isBusy: () => controller?.busy() ?? false,
@@ -312,17 +323,15 @@ export async function startClaudeIntercept<T>(options: StartClaudeInterceptOptio
312
323
  persistPreference,
313
324
  proxyPort: () => (pickerProxyLive && boundProxy ? boundProxy.port : null),
314
325
  configDir,
326
+ persistentAuthority: { store: options.pickerCaStore },
315
327
  ...(options.pickerSecurity ? { security: options.pickerSecurity } : {}),
316
328
  ...(options.pickerPlatform ? { platform: options.pickerPlatform } : {}),
317
329
  });
318
330
  await picker.start();
319
- // The rotated authority still needs the user's consent in the login keychain — a decline
320
- // reports trust_pending, and the picker stays a blind tunnel until trust is granted. When
321
- // Desktop was pinned to the picker before the restart, the regular enable flow re-trusts
322
- // the new authority and rewrites the profile row in place, so a restart does not silently
323
- // turn the picker off.
331
+ // Restore inspects the retained identity without installing trust. Explicit enable/trust
332
+ // owns consent when trust was revoked or legacy migration introduced a new authority.
324
333
  if (pickerProfileApplied && controller) {
325
- void controller.enable({ persist: false, context: "server" })
334
+ void controller.enable({ persist: false, context: "server", allowTrustPrompt: false })
326
335
  .catch(error => console.warn(`⚠ Claude Desktop picker restore failed: ${error instanceof Error ? error.message : String(error)}`));
327
336
  }
328
337
  }