@bitkyc08/opencodex 2.64.0 → 2.65.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/README.md +28 -25
- package/bin/ocx.mjs +16 -2
- package/gui/dist/assets/App-8NMiZxT0.css +1 -0
- package/gui/dist/assets/App-DGLte6IR.js +50 -0
- package/gui/dist/assets/Tray-D4bA1vff.js +1 -0
- package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
- package/gui/dist/assets/{index-SggB6t3z.js → index-Bi1K37Wl.js} +12 -12
- package/gui/dist/assets/tray-data-BZXhB3qz.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -4
- package/src/adapters/anthropic.ts +30 -1
- package/src/adapters/claude-cli/adapter.ts +218 -0
- package/src/adapters/claude-cli/profiles.ts +38 -0
- package/src/adapters/codebuddy/profiles.ts +2 -0
- package/src/adapters/coding-agent/profile.ts +12 -4
- package/src/adapters/coding-agent/turn.ts +16 -6
- package/src/adapters/command-code-tool-text.ts +133 -23
- package/src/adapters/cursor/catalog.ts +4 -8
- package/src/adapters/cursor/discovery.ts +10 -5
- package/src/adapters/cursor/effort-map.ts +2 -6
- package/src/adapters/cursor/envelope-echo.ts +10 -5
- package/src/adapters/cursor/request-builder.ts +9 -4
- package/src/adapters/cursor/thread-continuity.ts +53 -0
- package/src/adapters/cursor.ts +29 -13
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/google-tool-schema.ts +26 -3
- package/src/adapters/identity.ts +214 -2
- package/src/adapters/openai-chat/passthrough.ts +1 -0
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
- package/src/adapters/openai-chat.ts +43 -15
- package/src/adapters/openai-responses/canonical-forward.ts +17 -10
- package/src/adapters/openai-responses/passthrough.ts +19 -4
- package/src/adapters/openai-responses/request-strips.ts +25 -0
- package/src/adapters/openai-responses/web-search.ts +22 -4
- package/src/adapters/qoder/profiles.ts +2 -0
- package/src/adapters/registry.ts +8 -0
- package/src/adapters/run-turn-queue.ts +7 -0
- package/src/bridge/response-json.ts +6 -4
- package/src/bridge/sse.ts +7 -5
- package/src/claude/alias.ts +73 -26
- package/src/claude/context-windows.ts +15 -6
- package/src/claude/desktop-3p-library.ts +3 -2
- package/src/claude/desktop-3p.ts +1 -1
- package/src/claude/desktop-first-party.ts +63 -22
- package/src/claude/desktop-picker-profile.ts +302 -0
- package/src/claude/desktop-picker.ts +365 -0
- package/src/claude/desktop-risk.ts +20 -0
- package/src/claude/gateway-cache.ts +13 -4
- package/src/claude/inbound.ts +5 -2
- package/src/claude/intercept/connect-proxy.ts +106 -29
- package/src/claude/intercept/local-ca.ts +76 -18
- package/src/claude/intercept/model-bindings.ts +145 -0
- package/src/claude/intercept/picker-bootstrap.ts +141 -0
- package/src/claude/intercept/picker-ca.ts +126 -0
- package/src/claude/intercept/picker-listener.ts +213 -0
- package/src/claude/intercept/picker-models.ts +101 -0
- package/src/claude/intercept/picker-runtime.ts +330 -0
- package/src/claude/intercept/picker-trust.ts +125 -0
- package/src/claude/intercept/runtime.ts +149 -2
- package/src/claude/model-info.ts +18 -4
- package/src/cli/agent.ts +16 -2
- package/src/cli/capabilities.ts +71 -2
- package/src/cli/claude-desktop.ts +319 -33
- package/src/cli/claude.ts +15 -7
- package/src/cli/codex-shim-autorestore.ts +1 -0
- package/src/cli/dispatch.ts +29 -23
- package/src/cli/effort.ts +16 -6
- package/src/cli/ensure-desired-integrations.ts +29 -4
- package/src/cli/ready.ts +1 -1
- package/src/cli/registry.ts +8 -0
- package/src/cli/restart-scope.ts +9 -1
- package/src/cli/runtime-api.ts +17 -0
- package/src/cli/system-command.ts +11 -12
- package/src/client/connect.ts +12 -2
- package/src/client/state.ts +39 -1
- package/src/clients/config-export.ts +57 -9
- package/src/codex/auth-context.ts +51 -11
- package/src/codex/catalog/derive-entry.ts +5 -5
- package/src/codex/catalog/gather-capture.ts +25 -6
- package/src/codex/catalog/metadata.ts +3 -3
- package/src/codex/catalog/parsing.ts +39 -1
- package/src/codex/catalog/provider-models.ts +8 -6
- package/src/codex/catalog/retained-sync.ts +17 -7
- package/src/codex/catalog/sync.ts +2 -2
- package/src/codex/desktop-app-restart.ts +8 -0
- package/src/codex/desktop-switches.ts +9 -1
- package/src/codex/home.ts +43 -4
- package/src/codex/inject/config-toml.ts +137 -0
- package/src/codex/inject/plan.ts +23 -0
- package/src/codex/inject/remove.ts +21 -1
- package/src/codex/inject.ts +63 -8
- package/src/codex/journal.ts +36 -0
- package/src/codex/main-account-hard-lock.ts +14 -1
- package/src/codex/main-account-policy-wait.ts +109 -0
- package/src/codex/native-profile-startup.ts +24 -6
- package/src/codex/native-residue.ts +28 -10
- package/src/codex/quota-types.ts +8 -1
- package/src/codex/quota.ts +49 -7
- package/src/codex/runtime.ts +31 -10
- package/src/combos/failover.ts +7 -6
- package/src/combos/resolve.ts +91 -14
- package/src/combos/types.ts +18 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +21 -2
- package/src/config/schema/leaf-validators.ts +6 -3
- package/src/config/subagent-models.ts +3 -1
- package/src/generated/compatibility-version.json +275 -163
- package/src/images/artifacts.ts +14 -0
- package/src/integrations/owned-refresh.ts +1 -1
- package/src/integrations/ownership-policy.ts +32 -1
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +9 -0
- package/src/lib/config-ownership.ts +3 -0
- package/src/lib/errors.ts +87 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/request-execution-budget.ts +42 -14
- package/src/lib/request-resend-gate.ts +4 -3
- package/src/lib/retry-delay.ts +7 -1
- package/src/lib/tool-envelope-echo-filter.ts +236 -0
- package/src/lib/upstream-retry.ts +20 -12
- package/src/oauth/index.ts +1 -0
- package/src/oauth/kiro-credentials.ts +14 -1
- package/src/oauth/login-cli.ts +1 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/fast-opt-in.ts +31 -0
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/provider-id-rewrite.ts +2 -0
- package/src/providers/quota/vendor-probes-key.ts +8 -2
- package/src/providers/registry/entries-core.ts +45 -2
- package/src/providers/registry/entries-extended.ts +99 -12
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/types.ts +7 -1
- package/src/providers/resolved-model-policy.ts +11 -4
- package/src/providers/service-tier.ts +10 -3
- package/src/responses/code-mode-helper-compat.ts +3 -0
- package/src/responses/parser.ts +45 -3
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/claude-messages.ts +69 -14
- package/src/server/grok-upstream-envelope-echo.ts +120 -0
- package/src/server/index/claude-intercept-lifecycle.ts +24 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index.ts +7 -0
- package/src/server/management/agent-settings-routes.ts +204 -112
- package/src/server/management/claude-desktop-picker-routes.ts +128 -0
- package/src/server/management/combo-routes.ts +59 -2
- package/src/server/management/config-routes.ts +81 -39
- package/src/server/management/context.ts +3 -0
- package/src/server/management/native-integration-routes.ts +169 -93
- package/src/server/management/provider-routes.ts +46 -4
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/routing-profile-routes.ts +9 -0
- package/src/server/management/sidebar-routes.ts +52 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/proxy-liveness.ts +38 -3
- package/src/server/request-log-account-rotation.ts +53 -0
- package/src/server/request-log.ts +3 -16
- package/src/server/responses/adapter-dispatch.ts +17 -1
- package/src/server/responses/agent-task-recovery.ts +49 -26
- package/src/server/responses/codex-ws-exchange.ts +18 -7
- package/src/server/responses/codex-ws-wire.ts +27 -4
- package/src/server/responses/core-combo.ts +21 -3
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-opaque-recovery.ts +3 -2
- package/src/server/responses/encrypted-payload.ts +15 -14
- package/src/server/responses/fetch-helpers.ts +1 -1
- package/src/server/responses/input-admission.ts +10 -6
- package/src/server/responses/native-response-control.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +182 -12
- package/src/server/responses/passthrough-dispatch.ts +102 -44
- package/src/server/responses/policy-fallback.ts +3 -0
- package/src/server/responses/policy-refusal.ts +67 -0
- package/src/server/responses/request-send-budget.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +42 -11
- package/src/server/responses/ws-upstream.ts +2 -1
- package/src/server/system-env-shell.ts +0 -1
- package/src/server/system-env.ts +25 -4
- package/src/service/cli.ts +34 -3
- package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
- package/src/tray/windows-tray.ps1 +188 -7
- package/src/tray/windows.ts +23 -2
- package/src/types/config.ts +52 -12
- package/src/types/provider.ts +22 -5
- package/src/types/tools.ts +11 -5
- package/src/types.ts +1 -0
- package/src/update/async-check.ts +109 -0
- package/src/update/badge.ts +11 -17
- package/src/update/check-types.ts +9 -0
- package/src/update/desktop-badge.ts +102 -0
- package/src/update/index.ts +48 -8
- package/src/update/install-detection.d.mts +29 -2
- package/src/update/install-detection.mjs +213 -7
- package/src/update/job.ts +17 -12
- package/src/update/notify.ts +16 -11
- package/src/update/pnpm-owner-worker.ts +12 -0
- package/src/update/refresh-scheduler.ts +141 -0
- package/src/usage/cost.ts +31 -4
- package/src/usage/expected-prices.ts +57 -0
- package/assets/download-linux.svg +0 -10
- package/assets/download-macos.svg +0 -10
- package/assets/download-windows.svg +0 -10
- package/gui/dist/assets/App-CpuDF3ci.js +0 -50
- package/gui/dist/assets/App-I5AnaSLh.css +0 -1
- package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
- package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Desktop picker mode: the owned egress profile in Desktop's config library.
|
|
3
|
+
*
|
|
4
|
+
* The profile is an owned standard row named `opencodex-picker` whose file holds only
|
|
5
|
+
* `egressProxyUrl`, pointing Desktop at the dedicated picker CONNECT proxy
|
|
6
|
+
* (`ClaudeInterceptState.pickerProxyPort`). The previous selection is kept in opencodex state
|
|
7
|
+
* (`<configDir>/claude-picker/profile-state.json`), never in Desktop's `_meta.json`.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { withClientLifecycleSync } from "../client/lifecycle-lock";
|
|
13
|
+
import { atomicWriteFile, getConfigDir, withConfigMutationLockSync } from "../config";
|
|
14
|
+
import {
|
|
15
|
+
DESKTOP_PICKER_ENTRY_NAME,
|
|
16
|
+
isOwnedDesktopGatewayEntry,
|
|
17
|
+
parseMetadata,
|
|
18
|
+
profilePath,
|
|
19
|
+
resolveDesktop3pConfigLibraryPath,
|
|
20
|
+
SAFE_DESKTOP_PROFILE_ID,
|
|
21
|
+
type Desktop3pConfigLibraryOptions,
|
|
22
|
+
type Desktop3pMetadata,
|
|
23
|
+
type Desktop3pMetadataEntry,
|
|
24
|
+
} from "./desktop-3p-library";
|
|
25
|
+
|
|
26
|
+
// Keep a module-local rollback writer. Tests and callers may replace the public config writer to
|
|
27
|
+
// inject a forward-write failure; rollback must still be able to restore the prior bytes.
|
|
28
|
+
const rollbackAtomicWriteFile = atomicWriteFile;
|
|
29
|
+
|
|
30
|
+
export interface DesktopPickerProfileState { entryId: string; previousAppliedId: string | null }
|
|
31
|
+
export type DesktopPickerProfileInspection =
|
|
32
|
+
| { kind: "absent" }
|
|
33
|
+
| { kind: "applied"; entryId: string; proxyUrl: string }
|
|
34
|
+
| { kind: "not_selected"; entryId: string }
|
|
35
|
+
| { kind: "unsafe"; reason: string };
|
|
36
|
+
|
|
37
|
+
export type DesktopPickerProfileOptions = Desktop3pConfigLibraryOptions & { configDir?: string };
|
|
38
|
+
|
|
39
|
+
const PICKER_DIRECTORY = "claude-picker";
|
|
40
|
+
|
|
41
|
+
function pickerStatePath(configDir: string): string {
|
|
42
|
+
return join(configDir, PICKER_DIRECTORY, "profile-state.json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function metadataPath(libraryPath: string): string {
|
|
46
|
+
return join(libraryPath, "_meta.json");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function metadataJson(metadata: Desktop3pMetadata): string {
|
|
50
|
+
return JSON.stringify(metadata, null, 2) + "\n";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isValidMetadata(metadata: Desktop3pMetadata): boolean {
|
|
54
|
+
return metadata.entries.every(entry =>
|
|
55
|
+
entry !== null && typeof entry === "object" && typeof entry.id === "string" && typeof entry.name === "string");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readPickerState(path: string): DesktopPickerProfileState | null {
|
|
59
|
+
if (!existsSync(path)) return null;
|
|
60
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<DesktopPickerProfileState>;
|
|
61
|
+
if (typeof parsed.entryId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(parsed.entryId)) {
|
|
62
|
+
throw new Error("picker_profile_state_unreadable");
|
|
63
|
+
}
|
|
64
|
+
if (parsed.previousAppliedId !== null
|
|
65
|
+
&& parsed.previousAppliedId !== undefined
|
|
66
|
+
&& (typeof parsed.previousAppliedId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(parsed.previousAppliedId))) {
|
|
67
|
+
throw new Error("picker_profile_state_unreadable");
|
|
68
|
+
}
|
|
69
|
+
return { entryId: parsed.entryId, previousAppliedId: parsed.previousAppliedId ?? null };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readFileSnapshot(path: string): { exists: boolean; content?: string } {
|
|
73
|
+
return existsSync(path) ? { exists: true, content: readFileSync(path, "utf8") } : { exists: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function restoreFile(path: string, snapshot: { exists: boolean; content?: string }): void {
|
|
77
|
+
if (snapshot.exists) {
|
|
78
|
+
rollbackAtomicWriteFile(path, snapshot.content ?? "");
|
|
79
|
+
} else if (existsSync(path)) {
|
|
80
|
+
unlinkSync(path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function unlinkIfPresent(path: string): void {
|
|
85
|
+
if (existsSync(path)) unlinkSync(path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pickerEntries(metadata: Desktop3pMetadata): Desktop3pMetadataEntry[] {
|
|
89
|
+
return metadata.entries.filter(entry => entry.name === DESKTOP_PICKER_ENTRY_NAME);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pickerEntry(metadata: Desktop3pMetadata): Desktop3pMetadataEntry | undefined {
|
|
93
|
+
const entries = pickerEntries(metadata);
|
|
94
|
+
if (entries.length > 1) throw new Error("duplicate_picker_entries");
|
|
95
|
+
const entry = entries[0];
|
|
96
|
+
if (entry && !SAFE_DESKTOP_PROFILE_ID.test(entry.id)) throw new Error("unsafe_picker_id");
|
|
97
|
+
return entry;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function profileObject(path: string): Record<string, unknown> {
|
|
101
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
102
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
103
|
+
throw new Error("picker_profile_unreadable");
|
|
104
|
+
}
|
|
105
|
+
return parsed as Record<string, unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validPickerProfile(profile: Record<string, unknown>): profile is { egressProxyUrl: string } {
|
|
109
|
+
const keys = Object.keys(profile);
|
|
110
|
+
const proxyUrl = profile.egressProxyUrl;
|
|
111
|
+
const port = typeof proxyUrl === "string" ? /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/.exec(proxyUrl)?.[1] : undefined;
|
|
112
|
+
return keys.length === 1
|
|
113
|
+
&& keys[0] === "egressProxyUrl"
|
|
114
|
+
&& typeof proxyUrl === "string"
|
|
115
|
+
&& port !== undefined
|
|
116
|
+
&& Number(port) <= 65535;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function pickerEgressUrl(proxyPort: number): string {
|
|
120
|
+
return `http://127.0.0.1:${proxyPort}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type ApplyPickerProfileResult =
|
|
124
|
+
| { ok: true; changed: boolean; path: string }
|
|
125
|
+
| { ok: false; reason: "gateway_selected" | "foreign_unreadable" | "write_failed" };
|
|
126
|
+
type RemovePickerProfileResult = { ok: true; changed: boolean } | { ok: false; reason: string; residualPaths?: string[] };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Select the picker profile. Runs under the client lifecycle lock and the config mutation lock,
|
|
130
|
+
* the same boundary as the gateway writer (src/claude/desktop-3p.ts), so another process cannot
|
|
131
|
+
* interleave a gateway or catalog write with the selection.
|
|
132
|
+
*/
|
|
133
|
+
export function applyDesktopPickerProfile(options: { proxyPort: number } & DesktopPickerProfileOptions): ApplyPickerProfileResult {
|
|
134
|
+
try {
|
|
135
|
+
return withClientLifecycleSync(() => withConfigMutationLockSync(() => applyDesktopPickerProfileLocked(options)));
|
|
136
|
+
} catch {
|
|
137
|
+
return { ok: false, reason: "write_failed" };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Remove the picker profile under the same locks as `applyDesktopPickerProfile`. */
|
|
142
|
+
export function removeDesktopPickerProfile(options: DesktopPickerProfileOptions = {}): RemovePickerProfileResult {
|
|
143
|
+
try {
|
|
144
|
+
return withClientLifecycleSync(() => withConfigMutationLockSync(() => removeDesktopPickerProfileLocked(options)));
|
|
145
|
+
} catch (error) {
|
|
146
|
+
return { ok: false, reason: error instanceof Error ? error.message : "lock_unavailable" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function applyDesktopPickerProfileLocked(options: { proxyPort: number } & DesktopPickerProfileOptions): ApplyPickerProfileResult {
|
|
151
|
+
const libraryPath = resolveDesktop3pConfigLibraryPath(options);
|
|
152
|
+
const configDir = options.configDir ?? getConfigDir();
|
|
153
|
+
const metaPath = metadataPath(libraryPath);
|
|
154
|
+
let profile = join(libraryPath, "picker.json");
|
|
155
|
+
try {
|
|
156
|
+
if (!Number.isInteger(options.proxyPort) || options.proxyPort < 1 || options.proxyPort > 65535) {
|
|
157
|
+
return { ok: false, reason: "write_failed" };
|
|
158
|
+
}
|
|
159
|
+
mkdirSync(libraryPath, { recursive: true, mode: 0o700 });
|
|
160
|
+
const metadata = parseMetadata(metaPath);
|
|
161
|
+
if (!isValidMetadata(metadata)) return { ok: false, reason: "foreign_unreadable" };
|
|
162
|
+
if (metadata.appliedId !== undefined
|
|
163
|
+
&& (typeof metadata.appliedId !== "string" || !SAFE_DESKTOP_PROFILE_ID.test(metadata.appliedId))) {
|
|
164
|
+
return { ok: false, reason: "foreign_unreadable" };
|
|
165
|
+
}
|
|
166
|
+
const selectedId = typeof metadata.appliedId === "string" ? metadata.appliedId : null;
|
|
167
|
+
const selected = selectedId === null ? undefined : metadata.entries.find(entry => entry.id === selectedId);
|
|
168
|
+
if (selected && isOwnedDesktopGatewayEntry(selected)) return { ok: false, reason: "gateway_selected" };
|
|
169
|
+
|
|
170
|
+
const existing = pickerEntry(metadata);
|
|
171
|
+
const entryId = existing?.id ?? randomUUID();
|
|
172
|
+
profile = profilePath(libraryPath, entryId);
|
|
173
|
+
const target = JSON.stringify({ egressProxyUrl: pickerEgressUrl(options.proxyPort) }) + "\n";
|
|
174
|
+
const previousStatePath = pickerStatePath(configDir);
|
|
175
|
+
const alreadySelected = selectedId === entryId;
|
|
176
|
+
const priorState = readPickerState(previousStatePath);
|
|
177
|
+
const previousAppliedId = alreadySelected ? priorState?.previousAppliedId ?? null : selectedId;
|
|
178
|
+
const oldMeta = readFileSnapshot(metaPath);
|
|
179
|
+
const oldProfile = readFileSnapshot(profile);
|
|
180
|
+
const backupPath = `${profile}.bak`;
|
|
181
|
+
const oldBackup = readFileSnapshot(backupPath);
|
|
182
|
+
const oldState = readFileSnapshot(previousStatePath);
|
|
183
|
+
const changed = !alreadySelected || !oldProfile.exists || oldProfile.content !== target;
|
|
184
|
+
if (!changed) return { ok: true, changed: false, path: profile };
|
|
185
|
+
|
|
186
|
+
mkdirSync(join(configDir, PICKER_DIRECTORY), { recursive: true, mode: 0o700 });
|
|
187
|
+
try {
|
|
188
|
+
// Keep the same backup convention as the existing Desktop writer. It is removed with the
|
|
189
|
+
// picker row and also gives this transaction a private rollback source.
|
|
190
|
+
if (oldProfile.exists) atomicWriteFile(backupPath, oldProfile.content ?? "");
|
|
191
|
+
atomicWriteFile(profile, target);
|
|
192
|
+
if (!alreadySelected) {
|
|
193
|
+
atomicWriteFile(previousStatePath, JSON.stringify({ entryId, previousAppliedId }) + "\n");
|
|
194
|
+
}
|
|
195
|
+
const entries = existing
|
|
196
|
+
? metadata.entries.map(entry => entry.id === entryId ? { ...entry, name: DESKTOP_PICKER_ENTRY_NAME } : entry)
|
|
197
|
+
: [...metadata.entries, { id: entryId, name: DESKTOP_PICKER_ENTRY_NAME }];
|
|
198
|
+
atomicWriteFile(metaPath, metadataJson({ ...metadata, appliedId: entryId, entries }));
|
|
199
|
+
} catch {
|
|
200
|
+
try {
|
|
201
|
+
restoreFile(metaPath, oldMeta);
|
|
202
|
+
restoreFile(profile, oldProfile);
|
|
203
|
+
restoreFile(backupPath, oldBackup);
|
|
204
|
+
restoreFile(previousStatePath, oldState);
|
|
205
|
+
} catch {
|
|
206
|
+
// The public result remains deliberately opaque; callers can inspect the library.
|
|
207
|
+
}
|
|
208
|
+
return { ok: false, reason: "write_failed" };
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, changed: true, path: profile };
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
213
|
+
return { ok: false, reason: reason === "picker_profile_state_unreadable" || reason === "duplicate_picker_entries" || reason === "unsafe_picker_id" || reason === "picker_profile_unreadable"
|
|
214
|
+
? "foreign_unreadable" : "write_failed" };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function removeDesktopPickerProfileLocked(options: DesktopPickerProfileOptions): RemovePickerProfileResult {
|
|
219
|
+
const libraryPath = resolveDesktop3pConfigLibraryPath(options);
|
|
220
|
+
const configDir = options.configDir ?? getConfigDir();
|
|
221
|
+
const statePath = pickerStatePath(configDir);
|
|
222
|
+
const metaPath = metadataPath(libraryPath);
|
|
223
|
+
try {
|
|
224
|
+
if (!existsSync(metaPath)) {
|
|
225
|
+
const hadState = existsSync(statePath);
|
|
226
|
+
unlinkIfPresent(statePath);
|
|
227
|
+
return { ok: true, changed: hadState };
|
|
228
|
+
}
|
|
229
|
+
const metadata = parseMetadata(metaPath);
|
|
230
|
+
if (!isValidMetadata(metadata)) return { ok: false, reason: "metadata_unreadable" };
|
|
231
|
+
const picker = pickerEntry(metadata);
|
|
232
|
+
if (!picker) {
|
|
233
|
+
const hadState = existsSync(statePath);
|
|
234
|
+
unlinkIfPresent(statePath);
|
|
235
|
+
return { ok: true, changed: hadState };
|
|
236
|
+
}
|
|
237
|
+
const selected = metadata.appliedId === picker.id;
|
|
238
|
+
let state: DesktopPickerProfileState | null = null;
|
|
239
|
+
try { state = readPickerState(statePath); } catch { return { ok: false, reason: "profile_state_unreadable" }; }
|
|
240
|
+
|
|
241
|
+
let metadataAfterPivot = metadata;
|
|
242
|
+
if (selected) {
|
|
243
|
+
const previous = state?.entryId === picker.id ? state.previousAppliedId : null;
|
|
244
|
+
const previousExists = previous !== null && previous !== picker.id && metadata.entries.some(entry => entry.id === previous);
|
|
245
|
+
if (previousExists) {
|
|
246
|
+
metadataAfterPivot = { ...metadata, appliedId: previous };
|
|
247
|
+
} else {
|
|
248
|
+
const standardId = randomUUID();
|
|
249
|
+
atomicWriteFile(profilePath(libraryPath, standardId), "{}\n");
|
|
250
|
+
metadataAfterPivot = {
|
|
251
|
+
...metadata,
|
|
252
|
+
appliedId: standardId,
|
|
253
|
+
entries: [...metadata.entries, { id: standardId, name: "opencodex-standard" }],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
atomicWriteFile(metaPath, metadataJson(metadataAfterPivot));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const residualPaths: string[] = [];
|
|
260
|
+
for (const path of [profilePath(libraryPath, picker.id), `${profilePath(libraryPath, picker.id)}.bak`]) {
|
|
261
|
+
try { unlinkIfPresent(path); } catch { /* report the path without exposing file contents */ }
|
|
262
|
+
if (existsSync(path)) residualPaths.push(path);
|
|
263
|
+
}
|
|
264
|
+
if (residualPaths.length > 0) {
|
|
265
|
+
return { ok: false, reason: "cleanup_incomplete", residualPaths };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const entries = metadataAfterPivot.entries.filter(entry => entry.id !== picker.id);
|
|
269
|
+
try {
|
|
270
|
+
atomicWriteFile(metaPath, metadataJson({ ...metadataAfterPivot, entries }));
|
|
271
|
+
try { unlinkIfPresent(statePath); } catch { /* residual is reported below */ }
|
|
272
|
+
if (existsSync(statePath)) return { ok: false, reason: "cleanup_incomplete", residualPaths: [statePath] };
|
|
273
|
+
} catch {
|
|
274
|
+
return { ok: false, reason: "write_failed", residualPaths: [metaPath] };
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, changed: true };
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return { ok: false, reason: error instanceof Error ? error.message : "cleanup_failed" };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function inspectDesktopPickerProfile(options: DesktopPickerProfileOptions = {}): DesktopPickerProfileInspection {
|
|
283
|
+
const libraryPath = resolveDesktop3pConfigLibraryPath(options);
|
|
284
|
+
const metaPath = metadataPath(libraryPath);
|
|
285
|
+
if (!existsSync(metaPath)) return { kind: "absent" };
|
|
286
|
+
try {
|
|
287
|
+
const metadata = parseMetadata(metaPath);
|
|
288
|
+
if (!isValidMetadata(metadata)) return { kind: "unsafe", reason: "metadata_unreadable" };
|
|
289
|
+
const picker = pickerEntry(metadata);
|
|
290
|
+
if (!picker) return { kind: "absent" };
|
|
291
|
+
const path = profilePath(libraryPath, picker.id);
|
|
292
|
+
if (!existsSync(path)) return { kind: "unsafe", reason: "profile_missing" };
|
|
293
|
+
const profile = profileObject(path);
|
|
294
|
+
if (!validPickerProfile(profile)) return { kind: "unsafe", reason: "invalid_profile" };
|
|
295
|
+
return metadata.appliedId === picker.id
|
|
296
|
+
? { kind: "applied", entryId: picker.id, proxyUrl: profile.egressProxyUrl }
|
|
297
|
+
: { kind: "not_selected", entryId: picker.id };
|
|
298
|
+
} catch (error) {
|
|
299
|
+
const reason = error instanceof Error ? error.message : "metadata_unreadable";
|
|
300
|
+
return { kind: "unsafe", reason };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Desktop picker mode: the one server-side controller for every picker mutation.
|
|
3
|
+
*
|
|
4
|
+
* While a server runs, enable, disable and Desktop mode transitions run here, serialized by one
|
|
5
|
+
* lock (devlog/_plan/260924_claude_desktop_picker_mode/030_wp4_picker_activation.md, D11).
|
|
6
|
+
*/
|
|
7
|
+
import type { OcxConfig } from "../types";
|
|
8
|
+
import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
|
|
9
|
+
import { getConfigDir } from "../config/paths";
|
|
10
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
11
|
+
import { pickerCaCertPath, pickerCaFingerprints, ensurePickerCa, issuePickerLeaf, pickerLeafCertPath } from "./intercept/picker-ca";
|
|
12
|
+
import { inspectPickerTrust, trustPickerCa, untrustPickerCa } from "./intercept/picker-trust";
|
|
13
|
+
import type { PickerRuntime } from "./intercept/picker-runtime";
|
|
14
|
+
import type { PickerTrustState, SecurityRunner } from "./intercept/picker-trust";
|
|
15
|
+
import {
|
|
16
|
+
applyDesktopPickerProfile,
|
|
17
|
+
inspectDesktopPickerProfile,
|
|
18
|
+
removeDesktopPickerProfile,
|
|
19
|
+
pickerEgressUrl,
|
|
20
|
+
type DesktopPickerProfileInspection,
|
|
21
|
+
type DesktopPickerProfileOptions,
|
|
22
|
+
} from "./desktop-picker-profile";
|
|
23
|
+
import { resolveClaudeDesktopMode, observeClaudeDesktopMode } from "./desktop-first-party";
|
|
24
|
+
|
|
25
|
+
/** Desktop no longer selects the picker profile, so removing the CA's trust cannot strand it. */
|
|
26
|
+
function profileReleased(profile: DesktopPickerProfileInspection): boolean {
|
|
27
|
+
return profile.kind === "absent" || profile.kind === "not_selected";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DesktopPickerReason = "active" | "restart_required" | "unsupported_platform" | "not_first_party"
|
|
31
|
+
| "integration_off" | "disabled" | "proxy_unavailable" | "mode_not_committed" | "trust_pending"
|
|
32
|
+
| "trust_declined" | "profile_failed";
|
|
33
|
+
|
|
34
|
+
export interface DesktopPickerStatus {
|
|
35
|
+
desired: boolean;
|
|
36
|
+
supported: boolean;
|
|
37
|
+
trust: PickerTrustState;
|
|
38
|
+
profile: DesktopPickerProfileInspection["kind"];
|
|
39
|
+
listenerReady: boolean;
|
|
40
|
+
effective: boolean;
|
|
41
|
+
reason: DesktopPickerReason;
|
|
42
|
+
models: number;
|
|
43
|
+
snapshotAt: number | null;
|
|
44
|
+
lastBootstrapAt: number | null;
|
|
45
|
+
hint?: string;
|
|
46
|
+
residual?: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DesktopPickerEnableOptions {
|
|
50
|
+
persist: boolean;
|
|
51
|
+
context: "cli-trusted" | "server";
|
|
52
|
+
callerAddedTrust?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface DesktopPickerOps {
|
|
56
|
+
disableLocked(options: { persist: boolean }): Promise<DesktopPickerStatus>;
|
|
57
|
+
enableLocked(options: DesktopPickerEnableOptions): Promise<DesktopPickerStatus>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DesktopPickerController {
|
|
61
|
+
enable(options: DesktopPickerEnableOptions): Promise<DesktopPickerStatus>;
|
|
62
|
+
disable(options: { persist: boolean }): Promise<DesktopPickerStatus>;
|
|
63
|
+
transition<T>(fn: (ops: DesktopPickerOps) => Promise<T>): Promise<T>;
|
|
64
|
+
status(): Promise<DesktopPickerStatus>;
|
|
65
|
+
busy(): boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DesktopPickerControllerDeps {
|
|
69
|
+
runtime: PickerRuntime;
|
|
70
|
+
readConfig: () => OcxConfig;
|
|
71
|
+
persistPreference: (value: boolean) => boolean;
|
|
72
|
+
/** Bound picker CONNECT proxy port (Desktop's egress), or null when it is not running. */
|
|
73
|
+
proxyPort: () => number | null;
|
|
74
|
+
configDir: string;
|
|
75
|
+
security?: SecurityRunner;
|
|
76
|
+
platform?: NodeJS.Platform;
|
|
77
|
+
applyProfile?: typeof applyDesktopPickerProfile;
|
|
78
|
+
removeProfile?: typeof removeDesktopPickerProfile;
|
|
79
|
+
inspectProfile?: typeof inspectDesktopPickerProfile;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function profileOptions(deps: DesktopPickerControllerDeps): DesktopPickerProfileOptions {
|
|
83
|
+
return { configDir: deps.configDir, ...(deps.platform ? { platform: deps.platform } : {}) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function statusReasonForConfig(config: OcxConfig, platform: NodeJS.Platform, proxyBound: boolean): DesktopPickerReason {
|
|
87
|
+
if (platform !== "darwin") return "unsupported_platform";
|
|
88
|
+
if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "not_first_party";
|
|
89
|
+
if (!claudeDesktopIntegrationEnabled(config)) return "integration_off";
|
|
90
|
+
if (config.claudeCode?.intercept?.picker === false) return "disabled";
|
|
91
|
+
if (!proxyBound) return "proxy_unavailable";
|
|
92
|
+
return "restart_required";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emptyStatus(config: OcxConfig, platform: NodeJS.Platform, reason: DesktopPickerReason): DesktopPickerStatus {
|
|
96
|
+
return {
|
|
97
|
+
desired: platform === "darwin"
|
|
98
|
+
&& claudeDesktopIntegrationEnabled(config)
|
|
99
|
+
&& resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) === "first-party"
|
|
100
|
+
&& config.claudeCode?.intercept?.picker !== false,
|
|
101
|
+
supported: platform === "darwin",
|
|
102
|
+
trust: "unknown",
|
|
103
|
+
profile: "absent",
|
|
104
|
+
listenerReady: false,
|
|
105
|
+
effective: false,
|
|
106
|
+
reason,
|
|
107
|
+
models: 0,
|
|
108
|
+
snapshotAt: null,
|
|
109
|
+
lastBootstrapAt: null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createDesktopPickerController(deps: DesktopPickerControllerDeps): DesktopPickerController {
|
|
114
|
+
const platform = deps.platform ?? process.platform;
|
|
115
|
+
const applyProfile = deps.applyProfile ?? applyDesktopPickerProfile;
|
|
116
|
+
const removeProfile = deps.removeProfile ?? removeDesktopPickerProfile;
|
|
117
|
+
const inspectProfile = deps.inspectProfile ?? inspectDesktopPickerProfile;
|
|
118
|
+
let pending = 0;
|
|
119
|
+
// When this process last changed Desktop's egress profile. Desktop reads that profile only at
|
|
120
|
+
// launch, so a restart is needed only until it has fetched a bootstrap since that change; a plain
|
|
121
|
+
// opencodex restart changes nothing Desktop reads and never asks for one.
|
|
122
|
+
let profileChangedAt: number | null = null;
|
|
123
|
+
let tail = Promise.resolve();
|
|
124
|
+
|
|
125
|
+
function inspect(): DesktopPickerProfileInspection {
|
|
126
|
+
try { return inspectProfile(profileOptions(deps)); }
|
|
127
|
+
catch { return { kind: "unsafe", reason: "inspection_failed" }; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runtimeStatus(trustOverride?: PickerTrustState): DesktopPickerStatus {
|
|
131
|
+
const runtime = deps.runtime.status();
|
|
132
|
+
const profile = inspect();
|
|
133
|
+
const profileCurrent = profile.kind === "applied" && profile.proxyUrl === pickerEgressUrl(deps.proxyPort() ?? 0);
|
|
134
|
+
const trust = trustOverride ?? runtime.trust;
|
|
135
|
+
const effective = runtime.effective && profileCurrent && trust === "trusted";
|
|
136
|
+
let reason: DesktopPickerReason;
|
|
137
|
+
if (platform !== "darwin") reason = "unsupported_platform";
|
|
138
|
+
else if (!runtime.desired) reason = statusReasonForConfig(deps.readConfig(), platform, deps.proxyPort() !== null);
|
|
139
|
+
else if (deps.proxyPort() === null) reason = "proxy_unavailable";
|
|
140
|
+
else if (trust !== "trusted") reason = "trust_pending";
|
|
141
|
+
else if (!profileCurrent) reason = "profile_failed";
|
|
142
|
+
else if (!runtime.listenerReady || !runtime.effective) reason = "restart_required";
|
|
143
|
+
else reason = profileChangedAt !== null && (runtime.lastBootstrapAt === null || runtime.lastBootstrapAt < profileChangedAt)
|
|
144
|
+
? "restart_required"
|
|
145
|
+
: "active";
|
|
146
|
+
return {
|
|
147
|
+
desired: runtime.desired,
|
|
148
|
+
supported: runtime.supported,
|
|
149
|
+
trust,
|
|
150
|
+
profile: profile.kind,
|
|
151
|
+
listenerReady: runtime.listenerReady,
|
|
152
|
+
effective,
|
|
153
|
+
reason,
|
|
154
|
+
models: runtime.models,
|
|
155
|
+
snapshotAt: runtime.snapshotAt,
|
|
156
|
+
lastBootstrapAt: runtime.lastBootstrapAt,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
161
|
+
pending += 1;
|
|
162
|
+
let release!: () => void;
|
|
163
|
+
const previous = tail;
|
|
164
|
+
tail = new Promise<void>(resolve => { release = resolve; });
|
|
165
|
+
await previous;
|
|
166
|
+
try { return await fn(); }
|
|
167
|
+
finally { pending -= 1; release(); }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function untrustCurrentCa(): Promise<boolean> {
|
|
171
|
+
const caPath = pickerCaCertPath(deps.configDir);
|
|
172
|
+
if (platform !== "darwin" || !existsSync(caPath)) return true;
|
|
173
|
+
try {
|
|
174
|
+
const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1;
|
|
175
|
+
return (await untrustPickerCa(caPath, sha1, deps.security, platform)).ok;
|
|
176
|
+
} catch { return false; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function compensateTrust(shouldCompensate: boolean): Promise<boolean> {
|
|
180
|
+
if (!shouldCompensate) return true;
|
|
181
|
+
// An already selected owned profile proves that another successful enable still relies on
|
|
182
|
+
// this CA. Keep its trust even when the current request is refused.
|
|
183
|
+
if (inspect().kind === "applied") return true;
|
|
184
|
+
try {
|
|
185
|
+
const ca = ensurePickerCa(deps.configDir);
|
|
186
|
+
const result = await untrustPickerCa(
|
|
187
|
+
pickerCaCertPath(deps.configDir), pickerCaFingerprints(ca.certPem).sha1, deps.security, platform,
|
|
188
|
+
);
|
|
189
|
+
return result.ok;
|
|
190
|
+
} catch { return false; }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function withTrustFailure(status: DesktopPickerStatus, trustFailed: boolean): DesktopPickerStatus {
|
|
194
|
+
if (!trustFailed) return status;
|
|
195
|
+
return { ...status, effective: false, hint: "ocx claude desktop picker off", residual: [...new Set([...(status.residual ?? []), "trust"])] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function enableLocked(options: DesktopPickerEnableOptions): Promise<DesktopPickerStatus> {
|
|
199
|
+
let trustedByAttempt = false;
|
|
200
|
+
let observedTrust: PickerTrustState | undefined;
|
|
201
|
+
const refuse = async (reason: DesktopPickerReason): Promise<DesktopPickerStatus> => {
|
|
202
|
+
const trustFailed = options.callerAddedTrust === true && !(await compensateTrust(true));
|
|
203
|
+
return withTrustFailure({ ...runtimeStatus(observedTrust), reason }, trustFailed);
|
|
204
|
+
};
|
|
205
|
+
const check = (config: OcxConfig, includePreference: boolean): DesktopPickerReason | null => {
|
|
206
|
+
if (platform !== "darwin") return "unsupported_platform";
|
|
207
|
+
if (resolveClaudeDesktopMode(config, observeClaudeDesktopMode(config)) !== "first-party") return "mode_not_committed";
|
|
208
|
+
if (!claudeDesktopIntegrationEnabled(config)) return "integration_off";
|
|
209
|
+
if (includePreference && config.claudeCode?.intercept?.picker === false) return "disabled";
|
|
210
|
+
if (deps.proxyPort() === null) return "proxy_unavailable";
|
|
211
|
+
return null;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const first = check(deps.readConfig(), false);
|
|
215
|
+
if (first) return refuse(first);
|
|
216
|
+
if (options.persist) {
|
|
217
|
+
try {
|
|
218
|
+
if (!deps.persistPreference(true)) return refuse("mode_not_committed");
|
|
219
|
+
} catch { return refuse("mode_not_committed"); }
|
|
220
|
+
}
|
|
221
|
+
const second = check(deps.readConfig(), true);
|
|
222
|
+
if (second) return refuse(second);
|
|
223
|
+
|
|
224
|
+
let caPath: string;
|
|
225
|
+
let caSha1: string;
|
|
226
|
+
try {
|
|
227
|
+
const ca = ensurePickerCa(deps.configDir);
|
|
228
|
+
issuePickerLeaf(ca, deps.configDir);
|
|
229
|
+
caPath = pickerCaCertPath(deps.configDir);
|
|
230
|
+
caSha1 = pickerCaFingerprints(ca.certPem).sha1;
|
|
231
|
+
let trust = await inspectPickerTrust(pickerLeafCertPath(deps.configDir), caSha1, deps.security, platform);
|
|
232
|
+
observedTrust = trust;
|
|
233
|
+
if (trust !== "trusted" && options.context === "server") {
|
|
234
|
+
const added = await trustPickerCa(caPath, deps.security, platform);
|
|
235
|
+
trustedByAttempt = added.ok;
|
|
236
|
+
trust = await inspectPickerTrust(pickerLeafCertPath(deps.configDir), caSha1, deps.security, platform);
|
|
237
|
+
observedTrust = trust;
|
|
238
|
+
}
|
|
239
|
+
if (trust !== "trusted") {
|
|
240
|
+
const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true));
|
|
241
|
+
const result = withTrustFailure({ ...runtimeStatus(trust), reason: options.context === "cli-trusted" ? "trust_declined" : "trust_pending", hint: options.context === "cli-trusted" ? undefined : "ocx claude desktop picker trust" }, compensationFailed);
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
const afterTrust = check(deps.readConfig(), true);
|
|
245
|
+
if (afterTrust) {
|
|
246
|
+
const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true));
|
|
247
|
+
return withTrustFailure({ ...runtimeStatus(trust), reason: afterTrust }, compensationFailed);
|
|
248
|
+
}
|
|
249
|
+
const proxyPort = deps.proxyPort();
|
|
250
|
+
if (proxyPort === null) {
|
|
251
|
+
const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true));
|
|
252
|
+
return withTrustFailure({ ...runtimeStatus(trust), reason: "proxy_unavailable" }, compensationFailed);
|
|
253
|
+
}
|
|
254
|
+
const applied = applyProfile({ ...profileOptions(deps), proxyPort });
|
|
255
|
+
if (!applied.ok) {
|
|
256
|
+
const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true));
|
|
257
|
+
return withTrustFailure({ ...runtimeStatus(trust), reason: "profile_failed", residual: ["profile"] }, compensationFailed);
|
|
258
|
+
}
|
|
259
|
+
if (applied.changed) profileChangedAt = Date.now();
|
|
260
|
+
await deps.runtime.rearm();
|
|
261
|
+
const result = runtimeStatus();
|
|
262
|
+
if (result.reason === "active") return result;
|
|
263
|
+
return { ...result, reason: "restart_required" };
|
|
264
|
+
} catch {
|
|
265
|
+
const compensationFailed = (trustedByAttempt || options.callerAddedTrust === true) && !(await compensateTrust(true));
|
|
266
|
+
return withTrustFailure({ ...runtimeStatus(observedTrust), reason: "profile_failed" }, compensationFailed);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function disableLocked(options: { persist: boolean }): Promise<DesktopPickerStatus> {
|
|
271
|
+
const residual: string[] = [];
|
|
272
|
+
try { deps.runtime.disarm(); } catch { residual.push("runtime"); }
|
|
273
|
+
if (options.persist) {
|
|
274
|
+
try { if (!deps.persistPreference(false)) residual.push("preference"); }
|
|
275
|
+
catch { residual.push("preference"); }
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const removed = removeProfile(profileOptions(deps));
|
|
279
|
+
if (!removed.ok) residual.push("profile");
|
|
280
|
+
} catch { residual.push("profile"); }
|
|
281
|
+
// Trust goes only once Desktop no longer selects the picker profile: a selected profile without
|
|
282
|
+
// trust pins Desktop to a proxy whose certificate it rejects. Disarmed, the proxy only tunnels.
|
|
283
|
+
let trustRemoved = false;
|
|
284
|
+
if (profileReleased(inspect())) {
|
|
285
|
+
trustRemoved = await untrustCurrentCa();
|
|
286
|
+
if (!trustRemoved) residual.push("trust");
|
|
287
|
+
} else if (!residual.includes("profile")) {
|
|
288
|
+
residual.push("profile");
|
|
289
|
+
}
|
|
290
|
+
let trustAfter = deps.runtime.status().trust;
|
|
291
|
+
try { trustAfter = await deps.runtime.refreshTrust(); } catch { /* status below retains the runtime's cached trust */ }
|
|
292
|
+
const result = runtimeStatus(trustRemoved ? trustAfter === "trusted" ? "trusted" : "untrusted" : trustAfter);
|
|
293
|
+
const profile = inspect();
|
|
294
|
+
const trust = result.trust;
|
|
295
|
+
const effective = result.effective && profile.kind === "applied" && trust === "trusted";
|
|
296
|
+
const reason = residual.length > 0
|
|
297
|
+
? (residual.includes("trust") ? "trust_pending" : "profile_failed")
|
|
298
|
+
: configPickerDisabled(deps.readConfig()) ? "disabled" : profile.kind === "applied" ? "profile_failed" : "restart_required";
|
|
299
|
+
return { ...result, effective, reason, ...(residual.length > 0 ? { residual } : {}) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function configPickerDisabled(config: OcxConfig): boolean { return config.claudeCode?.intercept?.picker === false; }
|
|
303
|
+
const ops: DesktopPickerOps = { disableLocked, enableLocked };
|
|
304
|
+
return {
|
|
305
|
+
enable: options => withLock(() => enableLocked(options)),
|
|
306
|
+
disable: options => withLock(() => disableLocked(options)),
|
|
307
|
+
transition: fn => withLock(() => fn(ops)),
|
|
308
|
+
status: async () => runtimeStatus(),
|
|
309
|
+
busy: () => pending > 0,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function removeDesktopPickerArtifacts(options: { configDir?: string; security?: SecurityRunner; platform?: NodeJS.Platform } = {}): Promise<{ ok: boolean; residual?: string[] }> {
|
|
314
|
+
const configDir = options.configDir ?? getConfigDir();
|
|
315
|
+
const platform = options.platform ?? process.platform;
|
|
316
|
+
const residual: string[] = [];
|
|
317
|
+
try {
|
|
318
|
+
const removed = removeDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) });
|
|
319
|
+
if (!removed.ok) residual.push("profile");
|
|
320
|
+
} catch { residual.push("profile"); }
|
|
321
|
+
let released = false;
|
|
322
|
+
try { released = profileReleased(inspectDesktopPickerProfile({ configDir, ...(platform ? { platform } : {}) })); } catch { /* unknown: keep trust */ }
|
|
323
|
+
if (!released) {
|
|
324
|
+
// Still selected (or unknown): keep trust so Desktop is not pinned to a CA it rejects.
|
|
325
|
+
if (!residual.includes("profile")) residual.push("profile");
|
|
326
|
+
return { ok: false, residual };
|
|
327
|
+
}
|
|
328
|
+
const caPath = pickerCaCertPath(configDir);
|
|
329
|
+
if (platform === "darwin" && existsSync(caPath)) {
|
|
330
|
+
try {
|
|
331
|
+
const sha1 = pickerCaFingerprints(readFileSync(caPath, "utf8")).sha1;
|
|
332
|
+
if (!(await untrustPickerCa(caPath, sha1, options.security, platform)).ok) residual.push("trust");
|
|
333
|
+
} catch { residual.push("trust"); }
|
|
334
|
+
}
|
|
335
|
+
return residual.length ? { ok: false, residual } : { ok: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Static status when no controller runs in this process (intercept disabled, client role, bind failure). */
|
|
339
|
+
export function offlinePickerStatus(config: OcxConfig, platform: NodeJS.Platform = process.platform): DesktopPickerStatus {
|
|
340
|
+
const reason = statusReasonForConfig(config, platform, false);
|
|
341
|
+
return emptyStatus(config, platform, reason);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Run a Desktop mode transition under the controller lock, or, with no controller, with offline
|
|
346
|
+
* ops: disableLocked removes leftover picker artifacts; enableLocked reports proxy_unavailable.
|
|
347
|
+
*/
|
|
348
|
+
export function runDesktopTransition<T>(
|
|
349
|
+
controller: DesktopPickerController | null,
|
|
350
|
+
fn: (ops: DesktopPickerOps) => Promise<T>,
|
|
351
|
+
offline?: { configDir?: string; config: OcxConfig; security?: SecurityRunner; platform?: NodeJS.Platform },
|
|
352
|
+
): Promise<T> {
|
|
353
|
+
if (controller) return controller.transition(fn);
|
|
354
|
+
const fallback = offline ?? { config: {} as OcxConfig };
|
|
355
|
+
const platform = fallback.platform ?? process.platform;
|
|
356
|
+
const ops: DesktopPickerOps = {
|
|
357
|
+
disableLocked: async () => {
|
|
358
|
+
const cleanup = await removeDesktopPickerArtifacts({ configDir: fallback.configDir, security: fallback.security, platform });
|
|
359
|
+
const status = offlinePickerStatus(fallback.config, platform);
|
|
360
|
+
return cleanup.ok ? status : { ...status, effective: false, reason: "profile_failed", residual: cleanup.residual };
|
|
361
|
+
},
|
|
362
|
+
enableLocked: async () => ({ ...offlinePickerStatus(fallback.config, platform), reason: "proxy_unavailable" }),
|
|
363
|
+
};
|
|
364
|
+
return fn(ops);
|
|
365
|
+
}
|