Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,7 @@
"claude-intercept-local-ca.test.ts": "claude-integration",
"claude-intercept-proxy.test.ts": "claude-integration",
"claude-intercept-settings.test.ts": "claude-integration",
"claude-desktop-first-party.test.ts": "claude-integration",
"claude-intercept-integration.test.ts": "server"
},
"migrated": [
Expand Down
8 changes: 7 additions & 1 deletion src/claude/desktop-3p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ export function removeDesktop3pStandardPivot(
options: Desktop3pConfigLibraryOptions & {
appliedFingerprint?: string | null; unlink?: (path: string) => void;
lifecycleLockDeps?: ClientLifecycleLockDeps;
/**
* The desired-state guard below exists for OFF flows racing a concurrent enable. A
* mode switch (gateway → first-party) removes the profile while the integration stays
* ON on purpose, so the caller opts out of that guard.
*/
replaceWhileEnabled?: boolean;
} = {},
): Desktop3pRemovalResult {
const libraryPath = resolveDesktop3pConfigLibraryPath(options);
Expand All @@ -488,7 +494,7 @@ export function removeDesktop3pStandardPivot(
}
const latest = readConfigDiagnostics();
if (latest.source === "fallback") return { ok: false, changed: false, kind: "unsafe", libraryPath, reason: "desktop_config_invalid" };
if (claudeDesktopIntegrationEnabled(latest.config)) {
if (!options.replaceWhileEnabled && claudeDesktopIntegrationEnabled(latest.config)) {
const observed = inspectDesktop3pConfigLibrary(options);
if (observed.kind === "not_installed" || observed.kind === "no_owned_state") {
return { ok: true, changed: false, kind: "noop", libraryPath };
Expand Down
172 changes: 172 additions & 0 deletions src/claude/desktop-first-party.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Claude Desktop first-party mode.
*
* Desktop has two ways to reach opencodex:
*
* - `first-party` (default): the app keeps its ordinary claude.ai login, Chat tab, connectors
* and remote control. Only the Claude Code process it spawns for the Code tab (and that
* process's subagents) is redirected, through the `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` env
* in `~/.claude/settings.json` (src/claude/intercept/settings.ts) and the server's intercept
* pair (src/claude/intercept/runtime.ts). Nothing is written under Desktop's config library.
* - `gateway`: the historical third-party deployment profile (src/claude/desktop-3p.ts). The
* whole app is switched to a gateway build; picker entries are opencodex aliases.
*
* The two are mutually exclusive on disk: applying one removes the other. The mode is persisted
* in `claudeCode.desktopMode`; installs that predate the field but already carry an applied
* gateway profile keep `gateway` until they explicitly re-apply, so an update never flips a
* working Desktop under the operator.
*/
import { getConfigDir } from "../config/paths";
import type { OcxConfig } from "../types";
import { claudeInterceptCaCertPath, ensureLocalInterceptCa } from "./intercept/local-ca";
import { claudeInterceptEnabled, claudeInterceptProxyPort } from "./intercept/runtime";
import {
applyClaudeInterceptSettings,
buildClaudeInterceptEnv,
inspectClaudeInterceptSettings,
removeClaudeInterceptSettings,
type ClaudeInterceptEnv,
type ClaudeInterceptSettingsState,
type ClaudeInterceptSettingsWrite,
} from "./intercept/settings";

export const CLAUDE_DESKTOP_MODES = ["first-party", "gateway"] as const;
export type ClaudeDesktopMode = typeof CLAUDE_DESKTOP_MODES[number];
export const DEFAULT_CLAUDE_DESKTOP_MODE: ClaudeDesktopMode = "first-party";

export function isClaudeDesktopMode(value: unknown): value is ClaudeDesktopMode {
return typeof value === "string" && (CLAUDE_DESKTOP_MODES as readonly string[]).includes(value);
}

type DesktopModeConfig = Pick<OcxConfig, "claudeCode">;

/**
* Effective Desktop mode. An explicit `claudeCode.desktopMode` wins; otherwise a persisted
* gateway apply marker (`desktopProfile.appliedFingerprint`) means a pre-existing gateway
* install and keeps `gateway`; everything else is the first-party default.
*/
export function resolveClaudeDesktopMode(config: DesktopModeConfig): ClaudeDesktopMode {
const explicit = config.claudeCode?.desktopMode;
if (isClaudeDesktopMode(explicit)) return explicit;
if (config.claudeCode?.desktopProfile?.appliedFingerprint) return "gateway";
return DEFAULT_CLAUDE_DESKTOP_MODE;
}

/**
* Config mutation that records the applied Desktop mode. Switching to first-party also drops
* the gateway apply marker: the profile assignments stay for a later gateway apply, but a
* stale `appliedFingerprint` must not make `resolveClaudeDesktopMode` read `gateway` again
* should the explicit marker ever go missing.
*/
export function recordClaudeDesktopMode(
config: DesktopModeConfig,
mode: ClaudeDesktopMode,
): { changed: boolean; value: true } {
const claudeCode = config.claudeCode ?? {};
const profile = claudeCode.desktopProfile;
const dropMarker = mode === "first-party" && profile !== undefined
&& (profile.appliedFingerprint !== undefined || profile.appliedAt !== undefined);
if (claudeCode.desktopMode === mode && !dropMarker) return { changed: false, value: true };
if (dropMarker) {
const { appliedFingerprint: _fingerprint, appliedAt: _at, ...rest } = profile;
config.claudeCode = { ...claudeCode, desktopMode: mode, desktopProfile: rest };
} else {
config.claudeCode = { ...claudeCode, desktopMode: mode };
}
return { changed: true, value: true };
}

/**
* Mode an *apply* without an explicit choice should use. The first-party default only holds
* where the intercept proxy actually runs; with it disabled (or on a client role) an implied
* first-party apply would point Claude Code at a proxy that never starts, so fall back to the
* gateway profile. An explicit `desktopMode: "first-party"` is still honoured (and refused
* later with `intercept_disabled`, which names the fix).
*/
export function resolveClaudeDesktopApplyMode(
config: Pick<OcxConfig, "claudeCode" | "runtimeRole">,
): ClaudeDesktopMode {
const resolved = resolveClaudeDesktopMode(config);
if (resolved === "gateway" || isClaudeDesktopMode(config.claudeCode?.desktopMode)) return resolved;
return claudeInterceptEnabled(config) ? "first-party" : "gateway";
}

export interface DesktopFirstPartyTarget {
proxyPort: number;
caCertPath: string;
env: ClaudeInterceptEnv;
}

/** The settings env a first-party apply on this machine writes (CA is created on demand). */
export function desktopFirstPartyTarget(
config: Pick<OcxConfig, "claudeCode" | "port">,
opencodexConfigDir = getConfigDir(),
): DesktopFirstPartyTarget {
const proxyPort = claudeInterceptProxyPort(config, config.port ?? 10100);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the live intercept port for the settings target

When the server binds a different port from the persisted config.port—notably an ephemeral port: 0 or an automatic fallback because the configured port is occupied—the intercept runtime derives its listener from the actual bound public port, while this target writes HTTPS_PROXY from the stale persisted value (100 for port: 0). Apply and subsequent ensure/status inspection can therefore report first-party mode as current while every Claude Code request is sent to a dead port. Pass the live/bound proxy port into the first-party apply and inspection paths rather than deriving it solely from persisted configuration.

Useful? React with 👍 / 👎.

const caCertPath = claudeInterceptCaCertPath(opencodexConfigDir);
return { proxyPort, caCertPath, env: buildClaudeInterceptEnv(proxyPort, caCertPath) };
}

export interface DesktopFirstPartyInspection {
/** False when the server will not run the intercept pair (client role, intercept disabled). */
interceptEnabled: boolean;
proxyPort: number;
caCertPath: string;
settings: ClaudeInterceptSettingsState;
/** settings.json carries exactly the env the current config would write. */
applied: boolean;
/** Ours, but for an older port/config directory. Re-apply refreshes it. */
stale: boolean;
}

export interface DesktopFirstPartyOptions {
opencodexConfigDir?: string;
claudeConfigDir?: string;
}

export function inspectDesktopFirstParty(
config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole">,
options: DesktopFirstPartyOptions = {},
): DesktopFirstPartyInspection {
const target = desktopFirstPartyTarget(config, options.opencodexConfigDir);
const settings = inspectClaudeInterceptSettings(target.env, options.claudeConfigDir);
return {
interceptEnabled: claudeInterceptEnabled(config),
proxyPort: target.proxyPort,
caCertPath: target.caCertPath,
settings,
applied: settings.kind === "applied",
stale: settings.kind === "stale",
};
}

export type DesktopFirstPartyApplyResult =
| { ok: true; changed: boolean; path: string; env: ClaudeInterceptEnv; proxyPort: number }
| { ok: false; reason: "intercept_disabled" | "ca_unavailable" | "unreadable" | "foreign_env"; path: string };

/**
* Write the first-party env into Claude Code's settings. Creates the local CA first so the
* path we point `NODE_EXTRA_CA_CERTS` at exists before Claude Code ever reads it.
*/
export function applyDesktopFirstParty(
config: Pick<OcxConfig, "claudeCode" | "port" | "runtimeRole">,
options: DesktopFirstPartyOptions = {},
): DesktopFirstPartyApplyResult {
const opencodexConfigDir = options.opencodexConfigDir ?? getConfigDir();
const target = desktopFirstPartyTarget(config, opencodexConfigDir);
if (!claudeInterceptEnabled(config)) return { ok: false, reason: "intercept_disabled", path: "" };
try {
ensureLocalInterceptCa(opencodexConfigDir);
} catch {
return { ok: false, reason: "ca_unavailable", path: target.caCertPath };
}
const written = applyClaudeInterceptSettings(target.env, options.claudeConfigDir);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Convert settings write exceptions into apply failures

If the settings directory becomes unwritable, the disk is full, or the atomic rename fails, applyClaudeInterceptSettings throws from its mkdir/write/rename operations rather than returning its declared failure union. This wrapper catches only CA creation, so native-toggle and ensure callers can reject the request/startup reconciliation instead of returning the established refusal result; removeDesktopFirstParty has the same problem. Catch filesystem exceptions around both settings mutations and map them to a bounded failure reason such as write_failed.

AGENTS.md reference: src/AGENTS.md:L15-L17

Useful? React with 👍 / 👎.

if (!written.ok) return { ok: false, reason: written.reason, path: written.path };
return { ok: true, changed: written.changed, path: written.path, env: target.env, proxyPort: target.proxyPort };
}

/** Remove the first-party env. Only values anchored on our CA path are touched. */
export function removeDesktopFirstParty(options: DesktopFirstPartyOptions = {}): ClaudeInterceptSettingsWrite {
const caCertPath = claudeInterceptCaCertPath(options.opencodexConfigDir ?? getConfigDir());
return removeClaudeInterceptSettings(caCertPath, options.claudeConfigDir);
}
Loading
Loading