Skip to content
Draft
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
21 changes: 14 additions & 7 deletions src/cli/claude-desktop.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
import { loadConfig, saveConfigPreservingClaudeCode, type ConfigMutationSource } from "../config";
import { setIntegrationEnabled } from "../codex/desired-state";
import {
DESKTOP_FAMILIES,
Expand Down Expand Up @@ -45,15 +45,17 @@ export async function applyProfile(
profile: DesktopProfile,
mode: Desktop3pConfigMode,
deps: ApplyProfileDeps = {},
source?: ConfigMutationSource,
): Promise<{ ok: boolean; path: string; reason?: string; warning?: string }> {
// Explicit apply is an enable action. Persist intent before any Desktop write
// so a process crash cannot leave a gateway profile that startup immediately removes.
const desired = setIntegrationEnabled("claude-desktop", true);
const applySource = source ?? { surface: "cli", detail: "ocx claude desktop apply" };
const desired = setIntegrationEnabled("claude-desktop", true, applySource);
if (!desired.ok) return { ok: false, path: "", reason: desired.message };
const config = loadConfig();
const state = await buildClaudeDesktopState(config, profile);
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
saveConfigPreservingClaudeCode(config);
saveConfigPreservingClaudeCode(config, applySource);
const live = await (deps.findLiveProxyImpl ?? findLiveProxy)();
if (live) {
// #859: the Desktop alias reverse-map is process-local. Applying through the
Expand Down Expand Up @@ -195,7 +197,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
if (!state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`);
const profile = moveDesktopRoute(state.profile, route, familyRaw, flags.includes("--default"));
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile };
saveConfigPreservingClaudeCode(config);
saveConfigPreservingClaudeCode(config, { surface: "cli", detail: "ocx claude desktop move" });
console.log(`${route} 모델을 ${familyRaw} 그룹으로 옮겼습니다.`);
return 0;
}
Expand All @@ -206,7 +208,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`);
const profile = setDesktopFamilyDefault(state.profile, familyRaw, route);
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile };
saveConfigPreservingClaudeCode(config);
saveConfigPreservingClaudeCode(config, { surface: "cli", detail: "ocx claude desktop default" });
console.log(`${familyRaw} 기본 모델을 ${route ?? "없음"}으로 지정했습니다.`);
return 0;
}
Expand All @@ -225,9 +227,14 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf
const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8")));
const reconciled = (await buildClaudeDesktopState(config, profile)).profile;
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconciled };
saveConfigPreservingClaudeCode(config);
saveConfigPreservingClaudeCode(config, { surface: "cli", detail: "ocx claude desktop import" });
if (flags.includes("--apply")) {
const result = await applyProfile(reconciled, "static", deps);
const result = await applyProfile(
reconciled,
"static",
deps,
{ surface: "cli", detail: "ocx claude desktop import" },
);
if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; }
if (result.warning) console.warn(`⚠️ ${result.warning}`);
}
Expand Down
4 changes: 2 additions & 2 deletions src/cli/config-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
}
Object.assign(fresh, config);
return { changed: JSON.stringify(fresh) !== before, value: undefined };
});
}, { surface: "cli", detail: `ocx config ${action}` });
if (outcome.status === "unavailable") {
throw new Error(outcome.reason === "conflict"
? "config changed while applying this update; retry"
Expand Down Expand Up @@ -206,7 +206,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
if (!path) throw new CliUsageError("import path is required", USAGE);
if (!yes) throw new CliUsageError("import requires --yes", USAGE);
rejectArgs(args, USAGE);
saveConfig(validate(loadInput(path)));
saveConfig(validate(loadInput(path)), { surface: "cli", detail: "ocx config import" });
printData({ ok: true, source: path }, wantsJson, [`Imported config from ${path}. Restart or run ocx sync if needed.`]);
return;
}
Expand Down
10 changes: 8 additions & 2 deletions src/cli/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export interface CliDispatchDeps {

type CommandRunner = (deps: CliDispatchDeps) => Promise<number>;

/** Detail label for the shared restore/eject runner so audit provenance stays distinct. */
export function restoreCommandDetail(command: string | undefined): string {
return command === "eject" ? "ocx eject" : "ocx restore";
}

const commandRunners: Record<string, CommandRunner> = {
init: async () => {
const { runInit } = await import("./init");
Expand Down Expand Up @@ -97,7 +102,7 @@ const commandRunners: Record<string, CommandRunner> = {
if (!live) {
return emitBack(false, "No running proxy found. Run 'ocx start' — it injects opencodex automatically.", 1);
}
const desired = setIntegrationEnabled("codex", true);
const desired = setIntegrationEnabled("codex", true, { surface: "cli", detail: "ocx restore back" });
if (!desired.ok) {
return emitBack(false, `Codex desired state was not saved (${desired.reason}).`, desired.reason === "conflict" ? 2 : 1);
}
Expand All @@ -111,7 +116,8 @@ const commandRunners: Record<string, CommandRunner> = {
const target = collectOrcaCodexHomeDiagnostic();
return emitBack(true, `Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`, 0);
}
const desired = setIntegrationEnabled("codex", false);
const detail = restoreCommandDetail(deps.command);
const desired = setIntegrationEnabled("codex", false, { surface: "cli", detail });
if (!desired.ok) {
if (restoreJson) {
// Machine-readable contract: every restore --json outcome emits one
Expand Down
2 changes: 1 addition & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ async function chooseListenPort(
}
if (shouldPersistSelectedPort(config.port, selected, preferred, options)) {
config.port = selected;
saveConfig(config);
saveConfig(config, { surface: "cli", detail: "ocx start (port selection)" });
}
return selected;
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ export async function runInit(): Promise<void> {
modelDiscovery: { newModelPolicy: "off" },
};

saveConfig(config);
saveConfig(config, { surface: "cli", detail: "ocx init" });
// Init writes a fresh config, so a stale pre-migration backup from a previous
// installation would make the next `ocx start` crash on a stale-backup
// collision (issue #257). But only a STALE backup (unparseable, or already a
Expand Down
4 changes: 2 additions & 2 deletions src/cli/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ async function handleCustomAdd(args: string[]): Promise<void> {
addedAt: new Date().toISOString(),
};
config.customModels = [...existing, entry];
saveConfig(config);
saveConfig(config, { surface: "cli", detail: "ocx models add" });
await syncCustomModelsIfLive();
console.log(`Added custom model ${slug} (${entry.id}).`);
}
Expand Down Expand Up @@ -325,7 +325,7 @@ async function handleCustomRemove(args: string[]): Promise<void> {

const next = existing.filter((_, modelIndex) => modelIndex !== index);
config.customModels = next.length > 0 ? next : undefined;
saveConfig(config);
saveConfig(config, { surface: "cli", detail: "ocx models remove" });
await syncCustomModelsIfLive();
console.log(`Removed custom model ${routedSlug(model.provider, model.modelId)}.`);
}
Expand Down
10 changes: 5 additions & 5 deletions src/cli/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function maskSecret(value: string): string {
// Validation helper (F1 fix: validate before saveConfig)
// ---------------------------------------------------------------------------

function validateAndSave(config: ReturnType<typeof loadConfig>): void {
function validateAndSave(config: ReturnType<typeof loadConfig>, detail = "ocx provider set"): void {
if (!config.providers || Object.keys(config.providers).length === 0) {
console.error("Error: config would have no providers. Aborting.");
process.exit(1);
Expand All @@ -70,7 +70,7 @@ function validateAndSave(config: ReturnType<typeof loadConfig>): void {
console.error(`Error: defaultProvider "${config.defaultProvider}" does not exist in providers. Aborting.`);
process.exit(1);
}
saveConfig(config);
saveConfig(config, { surface: "cli", detail });
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -225,7 +225,7 @@ async function handleAdd(args: string[]): Promise<void> {
if (allowPrivateNetwork) provConfig.allowPrivateNetwork = true;
if (setDefault) config.defaultProvider = name;

validateAndSave(config);
validateAndSave(config, "ocx provider add");

if (wantsJson) {
console.log(JSON.stringify({
Expand Down Expand Up @@ -317,7 +317,7 @@ function handleRemove(args: string[]): void {

delete config.providers[name];
const droppedCustomModels = dropProviderCustomModels(config, name);
validateAndSave(config);
validateAndSave(config, "ocx provider remove");


if (wantsJson) {
Expand Down Expand Up @@ -411,7 +411,7 @@ function handleSetDefault(args: string[]): void {
}

config.defaultProvider = name;
validateAndSave(config);
validateAndSave(config, "ocx provider set-default");


if (wantsJson) {
Expand Down
4 changes: 2 additions & 2 deletions src/cli/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
}
if (modeArg === "default") deleteConfigTopLevelKey(cfg, "multiAgentMode");
else cfg.multiAgentMode = modeArg as "v1" | "v2";
saveConfig(cfg);
saveConfig(cfg, { surface: "cli", detail: "ocx v2 mode" });
try {
const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
await sync(findPort ? await findPort() : undefined);
Expand Down Expand Up @@ -235,7 +235,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
}
if (next) cfg.keepNativeChatGptOnV1 = true;
else deleteConfigTopLevelKey(cfg, "keepNativeChatGptOnV1");
saveConfig(cfg);
saveConfig(cfg, { surface: "cli", detail: "ocx v2 keep-native-v1" });
try {
const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
await sync(findPort ? await findPort() : undefined);
Expand Down
6 changes: 3 additions & 3 deletions src/client/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export function commitClientConnection(
config.client = structuredClone(state);
}
return { changed: !unchanged, value: undefined };
});
}, { surface: "cli", detail: "ocx connect: commit client connection" });
if (outcome.status === "committed" || outcome.status === "unchanged") return outcome.status;
if (outcome.status === "unavailable" && outcome.reason === "missing") {
// First ocx run on a fresh machine: ocx connect is the expected first command in
Expand All @@ -150,7 +150,7 @@ export function commitClientConnection(
const seeded = getDefaultConfig();
seeded.runtimeRole = "client";
seeded.client = structuredClone(state);
saveConfig(seeded);
saveConfig(seeded, { surface: "cli", detail: "ocx connect: commit client connection" });
return "committed";
}
throw new Error(`client state commit unavailable: ${"reason" in outcome ? outcome.reason : "unknown"}`);
Expand All @@ -169,7 +169,7 @@ export function clearClientConnection(
deleteConfigTopLevelKey(config, "client");
deleteConfigTopLevelKey(config, "runtimeRole");
return { changed: true, value: "committed" as const };
});
}, { surface: "cli", detail: "ocx disconnect: clear client connection" });
if (outcome.status === "unavailable") return "conflict";
return outcome.value;
}
9 changes: 7 additions & 2 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
saveConfigPreservingClaudeCode,
withConfigMutationLockSync,
} from "../config";
import type { ConfigMutationSource } from "../config-mutation-audit";
import { removeCodexAccountCredential } from "./account-store";
import { clearAccountNeedsReauth } from "./account-runtime-state";
import { getMainChatgptAccountId } from "./auth-collision";
Expand Down Expand Up @@ -127,7 +128,11 @@ function restorePersistedConfig(configPath: string, previousBytes: string): void
*
* Returns true when a picker-visible row disappeared and the catalog must converge.
*/
export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): boolean {
export function deleteCodexAccount(
runtimeConfig: OcxConfig,
accountId: string,
source: ConfigMutationSource = { surface: "internal", detail: "account lifecycle: remove account" },
): boolean {
let cleanupFailed = false;
const pickerVisibilityChanged = withConfigMutationLockSync(() => {
const previousConfig = structuredClone(runtimeConfig);
Expand Down Expand Up @@ -158,7 +163,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
try {
// Persist first for durable configs. Destructive cleanup below must never run for a
// deletion that failed to commit. Transient configs intentionally skip this write.
saveConfigPreservingClaudeCode(runtimeConfig);
saveConfigPreservingClaudeCode(runtimeConfig, source);
} catch (error) {
restoreRuntimeConfig(runtimeConfig, previousConfig);
try {
Expand Down
Loading
Loading