@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.
- package/gui/dist/assets/App-BOUa-ek3.js +54 -0
- package/gui/dist/assets/App-CSJvmjNs.css +1 -0
- package/gui/dist/assets/{Tray-DiJpSub0.js → Tray-DUtzB4zF.js} +1 -1
- package/gui/dist/assets/{index-DmQB8D_R.css → index-Brk6F3fn.css} +1 -1
- package/gui/dist/assets/index-DwHNDF4s.js +94 -0
- package/gui/dist/assets/{quota-summary-B_a96nXj.js → quota-summary-BuL4WIBo.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/devin.ts +22 -1
- package/src/claude/desktop-picker.ts +11 -9
- package/src/claude/inbound-content-options.ts +29 -2
- package/src/claude/intercept/local-ca-files.ts +265 -0
- package/src/claude/intercept/local-ca-windows.ts +73 -0
- package/src/claude/intercept/local-ca.ts +12 -24
- package/src/claude/intercept/picker-ca-persistence.ts +106 -0
- package/src/claude/intercept/picker-ca-startup.ts +16 -0
- package/src/claude/intercept/picker-ca-store.ts +70 -0
- package/src/claude/intercept/picker-ca.ts +43 -9
- package/src/claude/intercept/picker-runtime.ts +14 -4
- package/src/claude/intercept/runtime.ts +32 -23
- package/src/cli/ensure-readiness.ts +48 -0
- package/src/cli/gui-pair-client.ts +15 -7
- package/src/cli/gui.ts +4 -1
- package/src/cli/hub.ts +1 -0
- package/src/cli/index.ts +10 -11
- package/src/cli/status.ts +2 -2
- package/src/cli/update-restart-child.ts +4 -1
- package/src/codex/account-credit-use.ts +8 -2
- package/src/codex/auth-api/account-pause-group.ts +113 -0
- package/src/codex/auth-api/routes.ts +18 -12
- package/src/codex/auth-context.ts +45 -3
- package/src/codex/autostart-health.ts +10 -0
- package/src/codex/pool-credit-policy.ts +67 -0
- package/src/generated/compatibility-version.json +106 -46
- package/src/lib/gui-pair-capability.ts +1 -1
- package/src/lib/gui-pair-intent.ts +170 -0
- package/src/lib/windows-owner-acl.ts +143 -0
- package/src/providers/openai-sidecar-credit.ts +26 -0
- package/src/providers/openai-sidecar.ts +35 -0
- package/src/server/claude-messages.ts +1 -0
- package/src/server/gui-pair-delivery.ts +23 -0
- package/src/server/index/serve-options.ts +5 -8
- package/src/server/live-native-calls.ts +94 -0
- package/src/server/live.ts +28 -5
- package/src/server/local-management-read-client.ts +15 -11
- package/src/server/management/oauth-account-routes.ts +15 -0
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/shared.ts +9 -3
- package/src/server/messages-native-scope.ts +57 -0
- package/src/server/messages-native.ts +9 -0
- package/src/server/responses/adapter-dispatch.ts +31 -2
- package/src/server/responses/compact.ts +17 -2
- package/src/server/responses/core-auth.ts +18 -1
- package/src/server/responses/core-codex-account.ts +18 -8
- package/src/server/responses/non-replayable-error.ts +48 -0
- package/src/server/responses/passthrough-delivery.ts +5 -1
- package/src/server/responses/passthrough-dispatch.ts +21 -19
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/sidecar-execution.ts +1 -0
- package/src/server/search.ts +4 -0
- package/src/server/startup-health-cache.ts +2 -6
- package/src/vision/describe.ts +17 -10
- package/src/vision/index.ts +1 -0
- package/src/web-search/executor.ts +17 -10
- package/src/web-search/format-result.ts +304 -20
- package/src/web-search/loop.ts +2 -1
- package/src/web-search/passthrough-bridge.ts +1 -0
- package/src/web-search/run-turn-loop.ts +1 -1
- package/gui/dist/assets/App-BtfG5id-.js +0 -54
- package/gui/dist/assets/App-lCIslZ1L.css +0 -1
- 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 {
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 --
|
|
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
|
|
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
|
|
358
|
-
const existing = loadPersistedCa(
|
|
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
|
-
|
|
362
|
-
writeFileAtomic(join(dir, CLAUDE_INTERCEPT_CA_CERT_FILE), ca.certPem, 0o644);
|
|
350
|
+
files.writePair(ca);
|
|
363
351
|
return ca;
|
|
364
|
-
}
|
|
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
|
-
//
|
|
414
|
-
//
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
12
|
-
import {
|
|
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
|
-
//
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
320
|
-
//
|
|
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
|
}
|