@bitkyc08/opencodex 2.68.0 → 2.69.0-preview.20260928

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 (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-cDPyCb_V.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-H1IAB8mN.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CI6YTp8z.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-CutOlPu2.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-mpsibsFj.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-kUE8tuAK.js +0 -86
@@ -1,9 +1,9 @@
1
- import { createHash, X509Certificate } from "node:crypto";
2
- import { chmodSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
1
+ import { createHash, randomUUID, X509Certificate } from "node:crypto";
2
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { withClientLifecycleSync } from "../../client/lifecycle-lock";
4
5
  import {
5
- ALL_IP_ADDRESS_BASES,
6
- ensurePersistedAuthority,
6
+ createCertificateAuthority,
7
7
  issueServerLeaf,
8
8
  type LocalInterceptCa,
9
9
  type PemKeyPair,
@@ -15,10 +15,18 @@ export const PICKER_CA_COMMON_NAME = "opencodex Claude Desktop Picker CA";
15
15
  export const PICKER_STATE_DIR = "claude-picker";
16
16
 
17
17
  export interface PickerCa extends LocalInterceptCa { fingerprint: string }
18
+ export interface PendingPickerCaUntrust { certPem: string; sha1: string; sha256: string }
19
+
20
+ const processAuthorities = new Map<string, PickerCa>();
21
+ const MAX_PENDING_CA_BYTES = 64 * 1024;
22
+ const LEGACY_OWNER_MTIME_TOLERANCE_MS = 2_000;
18
23
 
19
24
  export function pickerStateDir(configDir: string): string { return join(configDir, PICKER_STATE_DIR); }
20
25
  export function pickerCaCertPath(configDir: string): string { return join(pickerStateDir(configDir), "ca.pem"); }
21
26
  export function pickerLeafCertPath(configDir: string): string { return join(pickerStateDir(configDir), "leaf.pem"); }
27
+ export function pickerCaOwnerPath(configDir: string): string { return join(pickerStateDir(configDir), "ca-owner.json"); }
28
+ export function pickerCaPendingUntrustPath(configDir: string): string { return join(pickerStateDir(configDir), "pending-untrust.json"); }
29
+ function pickerCaLockPath(configDir: string): string { return join(pickerStateDir(configDir), "ca.lock.sqlite"); }
22
30
 
23
31
  export function pickerCaFingerprints(certPem: string): { sha1: string; sha256: string } {
24
32
  const der = new X509Certificate(certPem).raw;
@@ -28,99 +36,291 @@ export function pickerCaFingerprints(certPem: string): { sha1: string; sha256: s
28
36
  };
29
37
  }
30
38
 
31
- interface DerItem { tag: number; body: Buffer; next: number }
39
+ /** Atomically publish a public certificate; these files carry no key material. */
40
+ function publishPem(path: string, pem: string): void {
41
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
42
+ try {
43
+ writeFileSync(tmp, pem, { flag: "wx", mode: 0o644 });
44
+ try { chmodSync(tmp, 0o644); } catch { /* best-effort on platforms without POSIX modes */ }
45
+ renameSync(tmp, path);
46
+ } finally {
47
+ rmSync(tmp, { force: true });
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Publish this process's authority and record which pid owns it, so another process that shares
53
+ * the config directory can distinguish "the file went missing or stale" from "a live peer rotated
54
+ * the authority" — only the former may be overwritten.
55
+ */
56
+ function publishAuthority(configDir: string, ca: PickerCa): void {
57
+ publishPem(pickerCaCertPath(configDir), ca.certPem);
58
+ publishOwner(configDir, ca);
59
+ }
60
+
61
+ function publishOwner(configDir: string, ca: PickerCa): void {
62
+ publishPem(pickerCaOwnerPath(configDir), JSON.stringify({
63
+ pid: process.pid,
64
+ startTime: processStartIdentity(process.pid),
65
+ sha256: ca.fingerprint,
66
+ }) + "\n");
67
+ }
68
+
69
+ function currentProcessOwnsPublishedCa(configDir: string, ca: PickerCa): boolean {
70
+ try {
71
+ const owner = JSON.parse(readFileSync(pickerCaOwnerPath(configDir), "utf8")) as Record<string, unknown>;
72
+ return owner.pid === process.pid && owner.sha256 === ca.fingerprint
73
+ && owner.startTime === processStartIdentity(process.pid);
74
+ } catch { return false; }
75
+ }
32
76
 
33
- function readDer(bytes: Buffer, at: number): DerItem | null {
34
- if (at + 2 > bytes.length) return null;
35
- const tag = bytes[at]!;
36
- let length = bytes[at + 1]!;
37
- let cursor = at + 2;
38
- if (length & 0x80) {
39
- const width = length & 0x7f;
40
- if (width === 0 || width > 4 || cursor + width > bytes.length) return null;
41
- length = 0;
42
- for (let i = 0; i < width; i++) length = length * 256 + bytes[cursor++]!;
77
+ /** The OS process start identity prevents a recycled PID from impersonating the recorded owner. */
78
+ function processStartIdentity(pid: number, utc = false): string | null {
79
+ if (process.platform === "linux") {
80
+ try {
81
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
82
+ const afterCommand = stat.lastIndexOf(") ");
83
+ const ticks = afterCommand < 0 ? undefined : stat.slice(afterCommand + 2).trim().split(/\s+/)[19];
84
+ return ticks && /^\d+$/.test(ticks) ? ticks : null;
85
+ } catch { return null; }
43
86
  }
44
- if (cursor + length > bytes.length) return null;
45
- return { tag, body: bytes.subarray(cursor, cursor + length), next: cursor + length };
87
+ if (process.platform === "darwin") {
88
+ try {
89
+ const result = Bun.spawnSync(["/bin/ps", "-o", "lstart=", "-p", String(pid)], {
90
+ stdin: "ignore", stdout: "pipe", stderr: "ignore",
91
+ ...(utc ? { env: { ...process.env, TZ: "UTC" } } : {}),
92
+ });
93
+ const value = result.stdout.toString().trim();
94
+ return result.exitCode === 0 && value.length > 0 && value.length <= 128 ? value : null;
95
+ } catch { return null; }
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /** Darwin's lstart is local wall-clock time; Linux's /proc start ticks are not comparable to mtime. */
101
+ function processStartEpochMs(pid: number): number | null {
102
+ if (process.platform !== "darwin") return null;
103
+ const identity = processStartIdentity(pid, true);
104
+ if (identity === null) return null;
105
+ const epoch = Date.parse(`${identity} UTC`);
106
+ return Number.isFinite(epoch) ? epoch : null;
46
107
  }
47
108
 
48
- function children(bytes: Buffer): DerItem[] | null {
49
- const out: DerItem[] = [];
50
- for (let cursor = 0; cursor < bytes.length;) {
51
- const item = readDer(bytes, cursor);
52
- if (!item) return null;
53
- out.push(item);
54
- cursor = item.next;
109
+ function processAlive(pid: number): boolean {
110
+ if (!Number.isInteger(pid) || pid <= 0) return false;
111
+ if (pid === process.pid) return true;
112
+ try {
113
+ process.kill(pid, 0);
114
+ return true;
115
+ } catch (error) {
116
+ // EPERM means the process exists but is not signallable by us; ESRCH means it is gone.
117
+ return error !== null && typeof error === "object" && (error as { code?: unknown }).code === "EPERM";
118
+ }
119
+ }
120
+
121
+ /**
122
+ * True when the published certificate belongs to a live process's authority. The owner
123
+ * record is only trusted while it describes the certificate actually on disk: a stale or
124
+ * third-party `ca-owner.json` cannot shield a file that was tampered with after the owner wrote it.
125
+ */
126
+ function livePublishedOwner(configDir: string, published: string): boolean {
127
+ try {
128
+ const owner = JSON.parse(readFileSync(pickerCaOwnerPath(configDir), "utf8")) as unknown;
129
+ if (owner === null || typeof owner !== "object") return false;
130
+ const { pid, sha256, startTime } = owner as { pid?: unknown; sha256?: unknown; startTime?: unknown };
131
+ if (typeof pid !== "number" || typeof sha256 !== "string") return false;
132
+ if (sha256 !== pickerCaFingerprints(published).sha256) return false;
133
+ if (!processAlive(pid)) return false;
134
+ // Older records have no start identity. On Darwin, wall-clock start after the owner
135
+ // file proves PID reuse; otherwise preserve the conservative live-owner decision.
136
+ if (startTime === undefined || startTime === null) {
137
+ const startedAt = processStartEpochMs(pid);
138
+ if (startedAt === null) return true;
139
+ let mtime: number;
140
+ try { mtime = statSync(pickerCaOwnerPath(configDir)).mtimeMs; }
141
+ catch { return true; }
142
+ return !Number.isFinite(mtime) || startedAt <= mtime + LEGACY_OWNER_MTIME_TOLERANCE_MS;
143
+ }
144
+ if (typeof startTime !== "string" || startTime.length === 0 || startTime.length > 128) return false;
145
+ const actual = processStartIdentity(pid);
146
+ return actual === null || actual === startTime;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ function publicCertificate(pem: string): PendingPickerCaUntrust | null {
153
+ try {
154
+ const certPem = new X509Certificate(pem).toString();
155
+ return { certPem, ...pickerCaFingerprints(certPem) };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /** The pending record is public-only, but malformed or unsafe state must never be discarded. */
162
+ export function readPendingPickerCaUntrust(configDir: string): PendingPickerCaUntrust | null {
163
+ const path = pickerCaPendingUntrustPath(configDir);
164
+ let stat;
165
+ try { stat = lstatSync(path); }
166
+ catch (error) {
167
+ if (error !== null && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") return null;
168
+ throw error;
169
+ }
170
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_PENDING_CA_BYTES) {
171
+ throw new Error("picker_ca_pending_untrust_unsafe");
172
+ }
173
+ const raw = readFileSync(path, "utf8");
174
+ if (Buffer.byteLength(raw) > MAX_PENDING_CA_BYTES) throw new Error("picker_ca_pending_untrust_unsafe");
175
+ let value: unknown;
176
+ try { value = JSON.parse(raw); }
177
+ catch { throw new Error("picker_ca_pending_untrust_invalid"); }
178
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
179
+ throw new Error("picker_ca_pending_untrust_invalid");
55
180
  }
56
- return out;
57
- }
58
-
59
- function constrainedToPickerHost(value: Buffer): boolean {
60
- const root = readDer(value, 0);
61
- if (!root || root.tag !== 0x30 || root.next !== value.length) return false;
62
- const fields = children(root.body);
63
- // permittedSubtrees [0] with exactly claude.ai, excludedSubtrees [1] with every IPv4 and IPv6 address.
64
- if (!fields || fields.length !== 2 || fields[0]!.tag !== 0xa0 || fields[1]!.tag !== 0xa1) return false;
65
- const excluded = children(fields[1]!.body);
66
- if (!excluded || excluded.length !== ALL_IP_ADDRESS_BASES.length) return false;
67
- const excludesAllIps = excluded.every((subtree, index) => {
68
- if (subtree.tag !== 0x30) return false;
69
- const base = children(subtree.body);
70
- return !!base && base.length === 1 && base[0]!.tag === 0x87
71
- && base[0]!.body.equals(Buffer.from(ALL_IP_ADDRESS_BASES[index]!));
181
+ const pending = value as Partial<PendingPickerCaUntrust>;
182
+ if (Object.keys(pending).sort().join(",") !== "certPem,sha1,sha256"
183
+ || typeof pending.certPem !== "string" || typeof pending.sha1 !== "string" || typeof pending.sha256 !== "string") {
184
+ throw new Error("picker_ca_pending_untrust_invalid");
185
+ }
186
+ const publicOnly = publicCertificate(pending.certPem);
187
+ if (!publicOnly || pending.certPem !== publicOnly.certPem
188
+ || pending.sha1 !== publicOnly.sha1 || pending.sha256 !== publicOnly.sha256) {
189
+ throw new Error("picker_ca_pending_untrust_invalid");
190
+ }
191
+ return publicOnly;
192
+ }
193
+
194
+ function writePendingPickerCaUntrust(configDir: string, pending: PendingPickerCaUntrust): void {
195
+ const path = pickerCaPendingUntrustPath(configDir);
196
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
197
+ try {
198
+ writeFileSync(temporary, JSON.stringify(pending) + "\n", { flag: "wx", mode: 0o600 });
199
+ try { chmodSync(temporary, 0o600); } catch { /* best-effort on platforms without POSIX modes */ }
200
+ renameSync(temporary, path);
201
+ } finally {
202
+ rmSync(temporary, { force: true });
203
+ }
204
+ }
205
+
206
+ /**
207
+ * A lock failure is separate from a successful void callback. Once the callback entered, an
208
+ * exception (including lock release failure) propagates: its effects may be partial.
209
+ */
210
+ function underPickerCaLock<T>(configDir: string, check: () => T):
211
+ | { kind: "acquired"; value: T }
212
+ | { kind: "unavailable"; error: unknown } {
213
+ let entered = false;
214
+ try {
215
+ const value = withClientLifecycleSync(() => {
216
+ entered = true;
217
+ return check();
218
+ }, { lockPath: pickerCaLockPath(configDir) });
219
+ return { kind: "acquired", value };
220
+ } catch (error) {
221
+ if (entered) throw error;
222
+ return { kind: "unavailable", error };
223
+ }
224
+ }
225
+
226
+ function lockedPickerCa<T>(configDir: string, check: () => T): T {
227
+ const result = underPickerCaLock(configDir, check);
228
+ if (result.kind === "unavailable") throw result.error;
229
+ return result.value;
230
+ }
231
+
232
+ function publishedPickerCa(configDir: string): string | null {
233
+ try { return readFileSync(pickerCaCertPath(configDir), "utf8"); }
234
+ catch (error) {
235
+ if (error !== null && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT") return null;
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ /** A pending CA may still be serving when journal publication preceded a failed PEM replacement. */
241
+ export function pendingPickerCaHasLivePublishedOwner(configDir: string, pending: PendingPickerCaUntrust): boolean {
242
+ return lockedPickerCa(configDir, () => {
243
+ const current = readPendingPickerCaUntrust(configDir);
244
+ if (!current || current.sha1 !== pending.sha1
245
+ || current.sha256 !== pending.sha256 || current.certPem !== pending.certPem) {
246
+ throw new Error("picker_ca_pending_untrust_changed");
247
+ }
248
+ const published = publishedPickerCa(configDir);
249
+ return published !== null && publicCertificate(published)?.sha256 === pending.sha256
250
+ && livePublishedOwner(configDir, published);
72
251
  });
73
- if (!excludesAllIps) return false;
74
- const subtrees = children(fields[0]!.body);
75
- if (!subtrees || subtrees.length !== 1 || subtrees[0]!.tag !== 0x30) return false;
76
- const subtree = children(subtrees[0]!.body);
77
- return !!subtree && subtree.length === 1 && subtree[0]!.tag === 0x82
78
- && subtree[0]!.body.equals(Buffer.from(PICKER_HOST, "ascii"));
79
- }
80
-
81
- /** Inspect the extension itself; X509Certificate does not expose nameConstraints. */
82
- function acceptsPickerAuthority(cert: X509Certificate): boolean {
83
- if (!cert.subject.split("\n").includes(`CN=${PICKER_CA_COMMON_NAME}`)) return false;
84
- const root = readDer(cert.raw, 0);
85
- if (!root || root.tag !== 0x30 || root.next !== cert.raw.length) return false;
86
- const certificate = children(root.body);
87
- if (!certificate || certificate[0]?.tag !== 0x30) return false;
88
- const tbs = children(certificate[0].body);
89
- const extensionField = tbs?.find(item => item.tag === 0xa3);
90
- const wrapped = extensionField && readDer(extensionField.body, 0);
91
- if (!wrapped || wrapped.tag !== 0x30 || wrapped.next !== extensionField!.body.length) return false;
92
- const extensions = children(wrapped.body);
93
- if (!extensions) return false;
94
- const matches = extensions.filter(item => {
95
- if (item.tag !== 0x30) return false;
96
- const fields = children(item.body);
97
- return fields?.[0]?.tag === 0x06 && fields[0].body.equals(Buffer.from([0x55, 0x1d, 0x1e]));
252
+ }
253
+
254
+ /** Acknowledgement never clears an entry another process created or replaced. */
255
+ export function acknowledgePendingPickerCaUntrust(
256
+ configDir: string,
257
+ pending: PendingPickerCaUntrust,
258
+ confirmedUntrust: { ok: boolean },
259
+ ): boolean {
260
+ if (confirmedUntrust?.ok !== true) return false;
261
+ return lockedPickerCa(configDir, () => {
262
+ const current = readPendingPickerCaUntrust(configDir);
263
+ if (!current || current.certPem !== pending.certPem
264
+ || current.sha1 !== pending.sha1 || current.sha256 !== pending.sha256) return false;
265
+ rmSync(pickerCaPendingUntrustPath(configDir));
266
+ return true;
267
+ });
268
+ }
269
+
270
+ /**
271
+ * Drop the legacy exportable signing key, if one exists in the picker state dir. Releases before
272
+ * the process-scoped authority persisted `ca.key` next to `ca.pem`; the removal is deliberately
273
+ * unconditional so a later failed rotation can never leave that key behind.
274
+ */
275
+ export function discardPickerCaKey(configDir: string): void {
276
+ rmSync(join(pickerStateDir(configDir), "ca.key"), { force: true });
277
+ }
278
+
279
+ export function ensurePickerCa(configDir: string, options: { rotation?: "startup" } = {}): PickerCa {
280
+ const dir = pickerStateDir(configDir);
281
+ // The signing key must never survive this process: another process under the same user could
282
+ // otherwise steal it and later take over the predictable loopback proxy. Drop a key left (or
283
+ // restored) by an older release on every call, including cache hits.
284
+ discardPickerCaKey(configDir);
285
+ const cached = processAuthorities.get(dir);
286
+ const pickerCa = cached ?? (() => {
287
+ const ca = createCertificateAuthority({ commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] });
288
+ return { ...ca, fingerprint: pickerCaFingerprints(ca.certPem).sha256 };
289
+ })();
290
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
291
+ lockedPickerCa(configDir, () => {
292
+ if (readPendingPickerCaUntrust(configDir)) throw new Error("picker_ca_pending_untrust");
293
+ const published = publishedPickerCa(configDir);
294
+ if (published === pickerCa.certPem) {
295
+ // The signing key is still in this process. A missing/stale owner file must not let a
296
+ // second process rotate this live authority after an otherwise harmless cached ensure.
297
+ if (!currentProcessOwnsPublishedCa(configDir, pickerCa)) publishOwner(configDir, pickerCa);
298
+ return;
299
+ }
300
+ if (published !== null && livePublishedOwner(configDir, published)) {
301
+ throw new Error("picker_ca_live_owner");
302
+ }
303
+ const outgoing = published === null ? null : publicCertificate(published);
304
+ if (outgoing && outgoing.sha256 !== pickerCa.fingerprint) {
305
+ if (options.rotation !== "startup") throw new Error("picker_ca_rotation_requires_startup");
306
+ writePendingPickerCaUntrust(configDir, outgoing);
307
+ }
308
+ publishAuthority(configDir, pickerCa);
98
309
  });
99
- if (matches.length !== 1) return false;
100
- const fields = children(matches[0]!.body);
101
- return !!fields && fields.length === 3
102
- && fields[1]!.tag === 0x01 && fields[1]!.body.equals(Buffer.from([0xff]))
103
- && fields[2]!.tag === 0x04 && constrainedToPickerHost(fields[2]!.body);
310
+ if (!cached) processAuthorities.set(dir, pickerCa);
311
+ return pickerCa;
104
312
  }
105
313
 
106
- export function ensurePickerCa(configDir: string): PickerCa {
107
- const ca = ensurePersistedAuthority(
108
- pickerStateDir(configDir),
109
- { commonName: PICKER_CA_COMMON_NAME, permittedDnsNames: [PICKER_HOST] },
110
- "ca-publication.sqlite",
111
- acceptsPickerAuthority,
112
- );
113
- return { ...ca, fingerprint: pickerCaFingerprints(ca.certPem).sha256 };
314
+ /** Fingerprint of this process's authority only — null until ensurePickerCa has run. */
315
+ export function publishedPickerCaSha256(configDir: string): string | null {
316
+ const cached = processAuthorities.get(pickerStateDir(configDir));
317
+ return cached ? pickerCaFingerprints(cached.certPem).sha256 : null;
114
318
  }
115
319
 
116
320
  /** Persist only the public leaf, so trust inspection verifies this exact local issuer. */
117
321
  export function issuePickerLeaf(ca: PickerCa, configDir: string): PemKeyPair {
118
322
  const leaf = issueServerLeaf(ca, PICKER_CA_COMMON_NAME, [PICKER_HOST]);
119
- const path = pickerLeafCertPath(configDir);
120
323
  mkdirSync(pickerStateDir(configDir), { recursive: true, mode: 0o700 });
121
- const tmp = `${path}.${process.pid}.tmp`;
122
- writeFileSync(tmp, leaf.certPem, { mode: 0o644 });
123
- try { chmodSync(tmp, 0o644); } catch { /* best-effort on platforms without POSIX modes */ }
124
- renameSync(tmp, path);
324
+ publishPem(pickerLeafCertPath(configDir), leaf.certPem);
125
325
  return leaf;
126
326
  }
@@ -7,6 +7,7 @@ import { dirname } from "node:path";
7
7
  import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../../codex/catalog";
8
8
  import type { OcxClaudeDesktopProfile } from "../../types";
9
9
  import { aliasForRoute, claudeCodeNativeAlias } from "../alias";
10
+ import { AUTO_CONTEXT_OFF, withOneMillionMarker } from "../context-windows";
10
11
  import { displayModelId, type Desktop3pRoutedModel } from "../desktop-3p";
11
12
  import { reconcileDesktopProfile, renderDesktopProfile, type DesktopProfileModel } from "../desktop-profile";
12
13
  import type { PickerModelEntry } from "./picker-bootstrap";
@@ -47,7 +48,9 @@ export function buildPickerModels(input: PickerRouteInput): PickerModelEntry[] {
47
48
  const alias = provider === "native" ? claudeCodeNativeAlias(id) : aliasForRoute(provider, id);
48
49
  if (!alias || seen.has(alias)) continue;
49
50
  seen.add(alias);
50
- out.push({ id: alias, name: model.label,
51
+ // Desktop runners do not inherit the proxy's compaction env: mark only real >=1M windows.
52
+ const selector = withOneMillionMarker(alias, model.contextWindow === undefined ? {} : { [alias]: model.contextWindow }, AUTO_CONTEXT_OFF)!;
53
+ out.push({ id: selector, name: model.label,
51
54
  ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }) });
52
55
  }
53
56
  return out;
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { PICKER_CA_COMMON_NAME, PICKER_HOST } from "./picker-ca";
@@ -84,17 +84,30 @@ export async function trustPickerCa(
84
84
  caPath: string,
85
85
  run: SecurityRunner = defaultSecurityRunner,
86
86
  platform: NodeJS.Platform = process.platform,
87
+ cert?: { pem?: string },
87
88
  ): Promise<{ ok: boolean; reason?: "unsupported" | "declined_or_failed" }> {
88
89
  if (platform !== "darwin") return { ok: false, reason: "unsupported" };
90
+ // `cert.pem` is the caller-verified certificate bytes: the shared on-disk ca.pem is writable by
91
+ // same-user processes and could be swapped between fingerprint inspection and this install, so
92
+ // the keychain command reads a private copy under a fresh 0700 directory instead.
93
+ let installPath = caPath;
94
+ let privateDir: string | undefined;
95
+ if (cert?.pem !== undefined) {
96
+ privateDir = mkdtempSync(join(tmpdir(), "ocx-picker-ca-"));
97
+ installPath = join(privateDir, "ca.pem");
98
+ writeFileSync(installPath, cert.pem, { mode: 0o600 });
99
+ }
89
100
  try {
90
101
  // No `-s <host>` policy string: Chromium (Claude Desktop) skips trust settings that carry one,
91
102
  // so a host-scoped setting leaves Desktop rejecting the picker leaf. The CA's critical name
92
103
  // constraints already limit it to claude.ai; macOS verify-cert rejects any other name.
93
104
  const result = await run(["add-trusted-cert", "-r", "trustRoot", "-p", "ssl",
94
- "-k", loginKeychainPath(), caPath]);
105
+ "-k", loginKeychainPath(), installPath]);
95
106
  return result.code === 0 ? { ok: true } : { ok: false, reason: "declined_or_failed" };
96
107
  } catch { // no-excuse-ok: catch -- user decline and command failure share a safe result.
97
108
  return { ok: false, reason: "declined_or_failed" };
109
+ } finally {
110
+ if (privateDir) rmSync(privateDir, { recursive: true, force: true });
98
111
  }
99
112
  }
100
113