diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4b0fc3b39a..57e0d93f79 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -873,6 +873,7 @@ export const de: Record = { "integrations.detail.desktopNotServed": "Das Profil ist da, Desktop nutzt aber ein anderes", "integrations.detail.desktopAbsent": "Kein Profil angewendet", "integrations.detail.desktopDesiredOff": "Die Claude-Desktop-Integration ist deaktiviert", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop verwendet das Gateway noch; die Bereinigung steht aus", "integrations.detail.desktopDesiredOnNotApplied": "Die Integration ist aktiviert, aber Desktop verwendet nicht das Gateway-Profil", "integrations.detail.desktopSelectedElsewhere": "Desktop verwendet ein anderes Profil", "integrations.detail.desktopProfileDrift": "Das ausgewählte Desktop-Profil wurde geändert", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index f8f5507062..ae3650e545 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1362,6 +1362,7 @@ export const en = { "integrations.detail.desktopNotServed": "The profile exists, but Desktop serves another one", "integrations.detail.desktopAbsent": "No profile applied", "integrations.detail.desktopDesiredOff": "Claude Desktop integration is off", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop is still using the gateway; cleanup is pending", "integrations.detail.desktopDesiredOnNotApplied": "Integration is on, but Desktop is not using the gateway profile", "integrations.detail.desktopSelectedElsewhere": "Desktop is using another profile", "integrations.detail.desktopProfileDrift": "The selected Desktop profile changed", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 9d0a83fdbe..cbd5b0b526 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1335,6 +1335,7 @@ export const fr: Record = { "integrations.detail.desktopNotServed": "Le profil existe, mais Desktop en utilise un autre", "integrations.detail.desktopAbsent": "Aucun profil appliqué", "integrations.detail.desktopDesiredOff": "L’intégration Claude Desktop est désactivée", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop utilise encore la passerelle ; le nettoyage est en attente", "integrations.detail.desktopDesiredOnNotApplied": "L’intégration est activée, mais Desktop n’utilise pas le profil de passerelle", "integrations.detail.desktopSelectedElsewhere": "Desktop utilise un autre profil", "integrations.detail.desktopProfileDrift": "Le profil Desktop sélectionné a changé", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index acc61bbd27..3031ae4f34 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1295,6 +1295,7 @@ export const ja: Record = { "integrations.detail.desktopNotServed": "プロファイルはありますが Desktop は別のものを使用中です", "integrations.detail.desktopAbsent": "適用されたプロファイルはありません", "integrations.detail.desktopDesiredOff": "Claude Desktop 連携はオフです", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop はまだゲートウェイを使用しています。クリーンアップ待ちです", "integrations.detail.desktopDesiredOnNotApplied": "連携はオンですが、Desktop はゲートウェイプロファイルを使用していません", "integrations.detail.desktopSelectedElsewhere": "Desktop は別のプロファイルを使用しています", "integrations.detail.desktopProfileDrift": "選択された Desktop プロファイルが変更されました", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 46d57057da..6536e54cd1 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -897,6 +897,7 @@ export const ko: Record = { "integrations.detail.desktopNotServed": "프로필은 있지만 Desktop이 다른 것을 씁니다", "integrations.detail.desktopAbsent": "적용된 프로필이 없습니다", "integrations.detail.desktopDesiredOff": "Claude Desktop 통합이 꺼져 있습니다", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop이 여전히 게이트웨이를 사용 중입니다. 정리 대기 중", "integrations.detail.desktopDesiredOnNotApplied": "통합은 켜져 있지만 Desktop이 게이트웨이 프로필을 사용하지 않습니다", "integrations.detail.desktopSelectedElsewhere": "Desktop이 다른 프로필을 사용 중입니다", "integrations.detail.desktopProfileDrift": "선택된 Desktop 프로필이 변경되었습니다", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a097a9e458..5d31cc95de 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1346,6 +1346,7 @@ export const ru: Record = { "integrations.detail.desktopNotServed": "Профиль есть, но Desktop использует другой", "integrations.detail.desktopAbsent": "Профиль не применён", "integrations.detail.desktopDesiredOff": "Интеграция Claude Desktop отключена", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop всё ещё использует шлюз; очистка не завершена", "integrations.detail.desktopDesiredOnNotApplied": "Интеграция включена, но Desktop не использует профиль шлюза", "integrations.detail.desktopSelectedElsewhere": "Desktop использует другой профиль", "integrations.detail.desktopProfileDrift": "Выбранный профиль Desktop был изменён", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b179272b62..2fb2ca52fc 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1353,6 +1353,7 @@ export const tr: Record = { "integrations.detail.desktopNotServed": "Profil mevcut ancak Desktop başkasını kullanıyor", "integrations.detail.desktopAbsent": "Uygulanan profil yok", "integrations.detail.desktopDesiredOff": "Claude Desktop entegrasyonu kapalı", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop hâlâ ağ geçidini kullanıyor; temizlik bekleniyor", "integrations.detail.desktopDesiredOnNotApplied": "Entegrasyon açık ancak Desktop kullanmıyor", "integrations.detail.desktopSelectedElsewhere": "Desktop başka bir profil kullanıyor", "integrations.detail.desktopProfileDrift": "Seçilen Desktop profili değişti", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index cf5d94b18a..db77768547 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1871,6 +1871,7 @@ export const zhTW: Record = { "integrations.detail.desktopNotServed": "設定檔存在,但 Desktop 使用的是另一個", "integrations.detail.desktopAbsent": "未套用任何設定檔", "integrations.detail.desktopDesiredOff": "Claude Desktop 整合已關閉", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop 仍在使用閘道,清理尚未完成", "integrations.detail.desktopDesiredOnNotApplied": "整合已開啟,但 Desktop 未使用閘道設定檔", "integrations.detail.desktopSelectedElsewhere": "Desktop 正在使用其他設定檔", "integrations.detail.desktopProfileDrift": "選取的 Desktop 設定檔已變更", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 51f7b8a366..38d6499c4b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -890,6 +890,7 @@ export const zh: Record = { "integrations.detail.desktopNotServed": "配置存在,但 Desktop 使用的是另一个", "integrations.detail.desktopAbsent": "未应用任何配置", "integrations.detail.desktopDesiredOff": "Claude Desktop 集成已关闭", + "integrations.detail.desktopDesiredOffCleanupPending": "Claude Desktop 仍在使用网关,清理尚未完成", "integrations.detail.desktopDesiredOnNotApplied": "集成已开启,但 Desktop 未使用网关配置", "integrations.detail.desktopSelectedElsewhere": "Desktop 正在使用其他配置", "integrations.detail.desktopProfileDrift": "选中的 Desktop 配置已更改", diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 14f3a24787..0df3ba18b2 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -326,6 +326,8 @@ export async function loadClaudeDesktopStatus(apiBase: string, signal?: AbortSig const body = await readOptional<{ applied?: unknown; stale?: unknown; + drift?: unknown; + driftReason?: unknown; activeProfile?: unknown; appliedAt?: unknown; desiredEnabled?: unknown; @@ -339,6 +341,8 @@ export async function loadClaudeDesktopStatus(apiBase: string, signal?: AbortSig observedKind: body.observedKind, applied: body.applied === true, stale: body.stale === true, + drift: body.drift === true, + driftReason: typeof body.driftReason === "string" ? body.driftReason : null, // Tri-state on purpose: `null` means undeterminable, which must not be // read as "Desktop is serving someone else's profile". activeProfile: typeof body.activeProfile === "boolean" ? body.activeProfile : null, diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 4142529c04..ecdf2788ba 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -107,6 +107,8 @@ export interface ClaudeDesktopPayload { observedKind?: string; applied?: boolean; stale?: boolean; + drift?: boolean; + driftReason?: string | null; activeProfile?: boolean | null; appliedAt?: string | null; } @@ -305,6 +307,30 @@ function claudeDesktopRow( return { ...base, toggle: null, state: "unknown", installed: false, applied: false, detailKey: null }; } const toggleOn = payload.desiredEnabled; + // Desired OFF keeps the switch off, but a still-selected gateway is not + // "absent": Desktop is still routing through OpenCodex until cleanup lands. + if (!toggleOn) { + const gatewayStillSelected = payload.applied === true + || payload.driftReason === "desired_off_gateway_selected"; + if (gatewayStillSelected) { + return { + ...base, + state: "stale", + installed: payload.installed === true, + applied: true, + toggleOn: false, + detailKey: "integrations.detail.desktopDesiredOffCleanupPending", + }; + } + return { + ...base, + state: "absent", + installed: payload.installed === true, + applied: false, + toggleOn: false, + detailKey: "integrations.detail.desktopDesiredOff", + }; + } if (payload.applied !== true) { return { ...base, @@ -312,7 +338,7 @@ function claudeDesktopRow( installed: payload.installed === true, applied: false, toggleOn, - detailKey: toggleOn ? "integrations.detail.desktopDesiredOnNotApplied" : "integrations.detail.desktopDesiredOff", + detailKey: "integrations.detail.desktopDesiredOnNotApplied", }; } const drifted = payload.stale === true || payload.activeProfile === false; diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 551ff7634d..b1e61ed8d5 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -109,6 +109,51 @@ test("Claude Desktop: applied but not the served profile reads as stale", () => sources({ native: desktopNative, claudeDesktop: { desiredEnabled: true, installed: true, applied: true, stale: false, activeProfile: null } }), ); expect(rowById(unknownProfile, "claudeDesktop").state).toBe("current"); + + // Desired OFF with the gateway gone is absent. + const desiredOff = buildOverviewRows( + sources({ + native: [{ ...desktopNative[0]!, desiredEnabled: false, state: "absent" }], + claudeDesktop: { desiredEnabled: false, installed: true, applied: false, stale: false, activeProfile: false }, + }), + ); + expect(rowById(desiredOff, "claudeDesktop")).toMatchObject({ + state: "absent", + applied: false, + toggleOn: false, + detailKey: "integrations.detail.desktopDesiredOff", + }); +}); + +test("Claude Desktop: desired-off with a still-selected gateway is stale cleanup-pending", () => { + const desktopNative = [{ + clientId: "claude-desktop" as const, + state: "current" as const, + installed: true, + configPath: "/tmp/desktop", + desiredEnabled: false, + disableBlocked: null, + }]; + const leftoverGateway = buildOverviewRows( + sources({ + native: desktopNative, + claudeDesktop: { + desiredEnabled: false, + installed: true, + applied: true, + stale: false, + drift: true, + driftReason: "desired_off_gateway_selected", + activeProfile: true, + }, + }), + ); + expect(rowById(leftoverGateway, "claudeDesktop")).toMatchObject({ + state: "stale", + applied: true, + toggleOn: false, + detailKey: "integrations.detail.desktopDesiredOffCleanupPending", + }); }); test("file clients keep their existing badge and applied semantics", () => { diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 3882583655..5fce7b147e 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -474,6 +474,12 @@ export function inspectDesktop3pConfigLibrary( * Select a credential-free standard profile before deleting an owned gateway. * The old metadata row remains as a retry locator only until both its profile * and backup are absent; successful cleanup removes it in the same operation. + * + * `gateway_drifted` is still an owned opencodex gateway (name + valid shape); the + * fingerprint only says on-disk bytes differ from the last saved marker. Refusing + * OFF for drift left users unable to disable after a lost `appliedFingerprint` + * (or any other benign mismatch), while the Integrations card still showed the + * leftover profile as applied/stale. */ export function removeDesktop3pStandardPivot( options: Desktop3pConfigLibraryOptions & { @@ -485,7 +491,7 @@ export function removeDesktop3pStandardPivot( if (inspected.kind === "not_installed" || inspected.kind === "no_owned_state") { return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; } - if (inspected.kind === "broken" || inspected.kind === "unsafe" || inspected.kind === "gateway_drifted") { + if (inspected.kind === "broken" || inspected.kind === "unsafe") { return { ok: false, changed: false, kind: "unsafe", libraryPath: inspected.libraryPath, reason: inspected.reason }; } if (!inspected.appliedId || !SAFE_DESKTOP_PROFILE_ID.test(inspected.appliedId)) { @@ -496,10 +502,13 @@ export function removeDesktop3pStandardPivot( try { const metadata = parseMetadata(metadataPath); const selectedId = inspected.appliedId; - // When Desktop is actively using our gateway, pivot only that selected row - // first. Any second owned row is residue for a later standard-mode retry; - // this preserves the selected-row preference after an interrupted cleanup. - const targetIds = inspected.kind === "gateway_ours" + // When Desktop is actively using our gateway (current or drifted), pivot only + // that selected row first. Any second owned row is residue for a later + // standard-mode retry; this preserves the selected-row preference after an + // interrupted cleanup. + const selectedOwnedGatewayActive = + inspected.kind === "gateway_ours" || inspected.kind === "gateway_drifted"; + const targetIds = selectedOwnedGatewayActive ? [selectedId] : metadata.entries .filter(isOwnedDesktopGatewayEntry) @@ -508,7 +517,7 @@ export function removeDesktop3pStandardPivot( if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath }; let metadataAfterPivot = metadata; - if (inspected.kind === "gateway_ours") { + if (selectedOwnedGatewayActive) { const standardId = randomUUID(); const standardPath = profilePath(inspected.libraryPath, standardId); atomicWriteFile(standardPath, "{}\n"); diff --git a/src/cli/ensure-desired-integrations.ts b/src/cli/ensure-desired-integrations.ts new file mode 100644 index 0000000000..82eb7fed76 --- /dev/null +++ b/src/cli/ensure-desired-integrations.ts @@ -0,0 +1,129 @@ +/** + * Align Grok and Claude Desktop files with the durable switches during `ocx ensure`. + * + * handleEnsure used to load config once, then health-probe / model-sync / spawn, + * and only afterwards mutate ~/.grok/config.toml and the Desktop library from + * that snapshot. An OFF→ON flip in that window stripped a freshly enabled fence + * or deleted a freshly applied Desktop profile; ON→OFF rewrote the files the + * user had just turned off. Re-read persisted desired state immediately before + * each external-file mutation, and use that current config for sync inputs. + */ +import { loadConfig } from "../config"; +import { stripGrokConfig, type GrokInjectResult } from "../grok/inject"; +import { removeDesktop3pStandardPivot } from "../claude/desktop-3p"; +import { + claudeDesktopIntegrationEnabled, + shouldSyncGrokOnStart, +} from "../codex/desired-state"; +import type { OcxConfig } from "../types"; + +export function grokSyncFailureMessage(err: unknown): string { + const detail = err instanceof Error ? err.message : String(err); + return `Grok Build config sync failed: ${detail}. ` + + "~/.grok/config.toml may still point at a previous proxy port — " + + "run 'ocx ensure' (or apply from the dashboard's Grok page) to repoint it."; +} + +export interface EnsureDesiredIntegrationsDeps { + loadConfig: () => OcxConfig; + stripGrokConfig: typeof stripGrokConfig; + syncGrokConfig: ( + port: number, + config: OcxConfig, + opts?: { hostname?: string }, + ) => Promise; + removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot; + log?: (message: string) => void; + error?: (message: string) => void; +} + +async function defaultSyncGrokConfig( + port: number, + config: OcxConfig, + opts: { hostname?: string } = {}, +): Promise { + const { syncGrokConfig } = await import("../grok/sync"); + return syncGrokConfig(port, config, opts); +} + +const productionDeps: EnsureDesiredIntegrationsDeps = { + loadConfig, + stripGrokConfig, + syncGrokConfig: defaultSyncGrokConfig, + removeDesktop3pStandardPivot, +}; + +function io(deps: EnsureDesiredIntegrationsDeps): { + log: (message: string) => void; + error: (message: string) => void; +} { + return { + log: deps.log ?? (message => console.log(message)), + error: deps.error ?? (message => console.error(message)), + }; +} + +/** + * Keep ~/.grok/config.toml aligned with the durable Grok switch. + * + * `handleStart` already gates inject on `shouldSyncGrokOnStart`. `ocx ensure` + * used to call `syncGrokConfig` unconditionally, so a dashboard/update/restart + * path that lands in ensure rewrote the fence while the switch stayed OFF. + * When the switch is OFF, strip any leftover managed block instead of injecting. + */ +export async function ensureGrokFenceMatchesDesired( + port: number, + opts: { hostname?: string } = {}, + deps: EnsureDesiredIntegrationsDeps = productionDeps, +): Promise { + const config = deps.loadConfig(); + const { log, error } = io(deps); + if (!shouldSyncGrokOnStart(config)) { + try { + const grok = deps.stripGrokConfig(); + if (grok.changed) log(` ↩️ ${grok.message}`); + else if (!grok.ok) error(`⚠️ ${grok.message}`); + } catch (err) { + error(`⚠️ ${grokSyncFailureMessage(err)}`); + } + return; + } + try { + const hostname = opts.hostname ?? config.hostname; + const g = await deps.syncGrokConfig( + port, + config, + hostname !== undefined ? { hostname } : {}, + ); + if (g.changed) log(" + Grok Build config updated (~/.grok/config.toml)"); + else if (!g.ok) error(`⚠️ ${g.message}`); + } catch (err) { + error(`⚠️ ${grokSyncFailureMessage(err)}`); + } +} + +/** + * When Claude Desktop is durably OFF, clear any leftover owned gateway profile. + * ensure/update used to leave Claude-3p residue in place after a failed disable + * (drifted fingerprint), so the Integrations card kept looking applied/stale. + */ +export function ensureClaudeDesktopMatchesDesired( + deps: EnsureDesiredIntegrationsDeps = productionDeps, +): void { + const config = deps.loadConfig(); + const { log, error } = io(deps); + if (claudeDesktopIntegrationEnabled(config)) return; + try { + const removed = deps.removeDesktop3pStandardPivot({ + appliedFingerprint: config.claudeCode?.desktopProfile?.appliedFingerprint ?? null, + }); + if (removed.ok && removed.changed) { + log(" ↩️ Claude Desktop integration residue removed."); + } else if (!removed.ok) { + error(`⚠️ Claude Desktop cleanup skipped: ${removed.reason ?? removed.kind}.`); + } + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + error(`⚠️ Claude Desktop cleanup failed: ${detail}.`); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e723db0e56..f160694a08 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -54,7 +54,15 @@ import { maybeShowStarPrompt } from "./star-prompt"; import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; -import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state"; +import { + shouldSyncGrokOnStart, + syncCodexOnStartIfEnabled, +} from "../codex/desired-state"; +import { + ensureClaudeDesktopMatchesDesired, + ensureGrokFenceMatchesDesired, + grokSyncFailureMessage, +} from "./ensure-desired-integrations"; /** * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing @@ -116,21 +124,6 @@ async function waitForProxy(timeoutMs = 8_000): Promise { return null; } -/** - * A Grok fence sync that throws is best-effort by design — it must never block startup. - * Reporting nothing, however, is what lets a STALE fence survive: `~/.grok/config.toml` - * keeps naming whatever port the last successful sync wrote, and once that listener is - * gone every grok turn retries against a refused connection while our own log stays - * silent (2026-07-27 field report: 8 entries pinned to a dead 127.0.0.1:4179). - * So say what failed and name the single command that repairs it. - */ -function grokSyncFailureMessage(err: unknown): string { - const detail = err instanceof Error ? err.message : String(err); - return `Grok Build config sync failed: ${detail}. ` - + "~/.grok/config.toml may still point at a previous proxy port — " - + "run 'ocx ensure' (or apply from the dashboard's Grok page) to repoint it."; -} - /** Argv for detached `start`, optionally hard-pinning the listen port. */ function startArgv(port?: number): string[] { const args = ["start"]; @@ -471,12 +464,14 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom reportShellHookFailure(reconcileShellHook(systemEnv.injected)); // Refresh the Grok Build fence too (same contract as start). live.hostname is the // hostname the running proxy actually bound — config.hostname may have drifted. - try { - const { syncGrokConfig } = await import("../grok/sync"); - const g = await syncGrokConfig(live.port, config, live.hostname ? { hostname: live.hostname } : {}); - if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); - else if (!g.ok) console.error(`⚠️ ${g.message}`); - } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } + // Re-read immediately before mutating client files: the snapshot above predates + // model sync and env reconcile, so a toggle during that window must win. + const current = loadConfig(); + await ensureGrokFenceMatchesDesired( + live.port, + live.hostname ? { hostname: live.hostname } : current.hostname ? { hostname: current.hostname } : {}, + ); + ensureClaudeDesktopMatchesDesired(); console.log(`✅ Proxy running on port ${live.port}`); return true; } @@ -496,15 +491,17 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom process.exitCode = 1; return false; } - // Deterministic fence guarantee: the spawned child injects late in its own startup, but - // this parent returns as soon as /healthz responds — inject here too (idempotent block - // replace) so `ocx ensure` never returns without the Grok fence in place. - try { - const { syncGrokConfig } = await import("../grok/sync"); - const g = await syncGrokConfig(port, config, config.hostname ? { hostname: config.hostname } : {}); - if (g.changed) console.log(" + Grok Build config updated (~/.grok/config.toml)"); - else if (!g.ok) console.error(`⚠️ ${g.message}`); - } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); } + // Deterministic fence guarantee when the durable switch is ON: the spawned child + // injects late in its own startup, but this parent returns as soon as /healthz + // responds — align here too so `ocx ensure` never returns with a stale ON/OFF mismatch. + // Re-read after waitForProxy: the snapshot taken before spawn is stale if the + // user flipped Grok or Claude Desktop while the child was coming up. + const current = loadConfig(); + await ensureGrokFenceMatchesDesired( + port, + current.hostname ? { hostname: current.hostname } : {}, + ); + ensureClaudeDesktopMatchesDesired(); // Always sync the LIVE port: after a fallback-port start, config.port still names the // busy preferred port — syncing that would point Codex at a dead listener. const synced = await syncModelsToCodex(port).catch(e => { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 53a35e8756..2fbee7d5a9 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -945,7 +945,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const observed = inspectDesktop3pConfigLibrary({ appliedFingerprint: savedFingerprint }); const desiredEnabled = claudeDesktopIntegrationEnabled(persisted); const applied = observed.kind === "gateway_ours" || observed.kind === "gateway_drifted"; - const stale = observed.kind === "gateway_drifted"; + // "Needs update" is only meaningful while the integration is wanted. When the + // durable switch is OFF, a leftover drifted profile is residue to clear — not + // a stale apply the operator should refresh. + const stale = desiredEnabled && observed.kind === "gateway_drifted"; const { getDesktopHealth } = await import("../../claude/desktop-health"); const health = getDesktopHealth(); return jsonResponse({ diff --git a/tests/desktop-3p-removal.test.ts b/tests/desktop-3p-removal.test.ts index fdd5f1edd3..ecbc34a417 100644 --- a/tests/desktop-3p-removal.test.ts +++ b/tests/desktop-3p-removal.test.ts @@ -78,7 +78,7 @@ test("a selected foreign standard profile is never mutated, but owned residue ca expect(JSON.parse(readFileSync(join(library, "_meta.json"), "utf8"))).toMatchObject({ appliedId: foreign, entries: [{ id: foreign }] }); }); -test("an owned but drifted gateway profile is refused without a write", () => { +test("an owned but drifted gateway profile can still be disabled", () => { const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); const id = "drifted-owned"; writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); @@ -86,12 +86,35 @@ test("an owned but drifted gateway profile is refused without a write", () => { inferenceProvider: "gateway", inferenceCredentialKind: "static", inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", })); - const before = readFileSync(join(library, "_meta.json"), "utf8"); + writeFileSync(join(library, `${id}.json.bak`), "{}"); expect(inspectDesktop3pConfigLibrary({ env: envFor(library), appliedFingerprint: "other" }).kind).toBe("gateway_drifted"); - expect(removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: "other" })).toMatchObject({ ok: false, changed: false, kind: "unsafe" }); - expect(readFileSync(join(library, "_meta.json"), "utf8")).toBe(before); - expect(existsSync(join(library, `${id}.json`))).toBe(true); + // Missing/mismatched fingerprint must not trap OFF: the selected row is still our + // owned gateway, so disable pivots to standard and deletes the credential-bearing files. + const result = removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: "other" }); + expect(result).toMatchObject({ ok: true, changed: true, kind: "removed" }); + expect(existsSync(join(library, `${id}.json`))).toBe(false); + expect(existsSync(join(library, `${id}.json.bak`))).toBe(false); + const metadata = JSON.parse(readFileSync(join(library, "_meta.json"), "utf8")) as { appliedId: string; entries: Array<{ id: string; name: string }> }; + expect(metadata.entries.map(entry => entry.id)).not.toContain(id); + expect(metadata.entries.some(entry => entry.name === "opencodex-standard")).toBe(true); + expect(JSON.parse(readFileSync(join(library, `${metadata.appliedId}.json`), "utf8"))).toEqual({}); +}); + +test("an owned gateway with no saved fingerprint is treated as drifted and can be disabled", () => { + const library = mkdtempSync(join(tmpdir(), "ocx-desktop-remove-")); + const id = "fingerprint-missing"; + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", inferenceGatewayApiKey: "not-a-secret", + })); + + expect(inspectDesktop3pConfigLibrary({ env: envFor(library), appliedFingerprint: null }).kind).toBe("gateway_drifted"); + expect(removeDesktop3pStandardPivot({ env: envFor(library), appliedFingerprint: null })).toMatchObject({ + ok: true, changed: true, kind: "removed", + }); + expect(existsSync(join(library, `${id}.json`))).toBe(false); }); test("a delete interruption leaves the standard pivot selected and reports only residual paths", () => { diff --git a/tests/ensure-desired-integrations-race.test.ts b/tests/ensure-desired-integrations-race.test.ts new file mode 100644 index 0000000000..9a827cc613 --- /dev/null +++ b/tests/ensure-desired-integrations-race.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { + ensureClaudeDesktopMatchesDesired, + ensureGrokFenceMatchesDesired, + type EnsureDesiredIntegrationsDeps, +} from "../src/cli/ensure-desired-integrations"; +import type { OcxConfig } from "../src/types"; +import type { GrokInjectResult } from "../src/grok/inject"; +import type { Desktop3pRemovalResult } from "../src/claude/desktop-3p"; + +function config(overrides: { + grok?: boolean; + desktop?: boolean; + hostname?: string; + fingerprint?: string; +} = {}): OcxConfig { + const clientIntegrations: NonNullable = {}; + if (overrides.grok === false) clientIntegrations.grok = false; + if (overrides.desktop === false) clientIntegrations["claude-desktop"] = false; + return { + port: 10100, + providers: {}, + defaultProvider: "openai", + hostname: overrides.hostname, + clientIntegrations: Object.keys(clientIntegrations).length > 0 ? clientIntegrations : undefined, + claudeCode: { + desktopProfile: { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + appliedFingerprint: overrides.fingerprint ?? "fp-stale", + }, + }, + }; +} + +function okGrok(changed = true): GrokInjectResult { + return { ok: true, changed, message: changed ? "updated" : "unchanged" }; +} + +function removedDesktop(): Desktop3pRemovalResult { + return { ok: true, changed: true, kind: "removed", libraryPath: "/tmp/desktop" }; +} + +function harness(initial: OcxConfig) { + let current = initial; + const grokActions: Array<{ action: "strip" | "sync"; config: OcxConfig; hostname?: string }> = []; + const desktopActions: Array<{ action: "remove" | "skip"; fingerprint: string | null }> = []; + const deps: EnsureDesiredIntegrationsDeps = { + loadConfig: () => current, + stripGrokConfig: () => { + grokActions.push({ action: "strip", config: current }); + return okGrok(true); + }, + syncGrokConfig: async (_port, cfg, opts) => { + grokActions.push({ action: "sync", config: cfg, hostname: opts?.hostname }); + return okGrok(true); + }, + removeDesktop3pStandardPivot: options => { + desktopActions.push({ + action: "remove", + fingerprint: options.appliedFingerprint ?? null, + }); + return removedDesktop(); + }, + log: () => {}, + error: () => {}, + }; + return { + grokActions, + desktopActions, + deps, + flip(next: OcxConfig) { + current = next; + }, + }; +} + +/** + * Live-proxy branch: snapshot, then model-sync/env (the race window), then mutate. + * Spawned-proxy branch: snapshot, then waitForProxy, then mutate with the current hostname. + */ +async function runLiveBranch(h: ReturnType, liveHostname = "127.0.0.1"): Promise { + const stale = h.deps.loadConfig(); + void stale; + await ensureGrokFenceMatchesDesired( + 10100, + liveHostname ? { hostname: liveHostname } : {}, + h.deps, + ); + ensureClaudeDesktopMatchesDesired(h.deps); +} + +async function runSpawnedBranch(h: ReturnType): Promise { + const stale = h.deps.loadConfig(); + void stale; + const current = h.deps.loadConfig(); + await ensureGrokFenceMatchesDesired( + 10100, + current.hostname ? { hostname: current.hostname } : {}, + h.deps, + ); + ensureClaudeDesktopMatchesDesired(h.deps); +} + +describe("ensure desired-state races", () => { + test("live-proxy OFF→ON uses the current ON snapshot instead of stripping", async () => { + const staleOff = config({ grok: false, desktop: false, hostname: "stale-host", fingerprint: "fp-off" }); + const currentOn = config({ hostname: "fresh-host", fingerprint: "fp-on" }); + const h = harness(staleOff); + h.flip(currentOn); + await runLiveBranch(h, "live-bound"); + expect(h.grokActions).toEqual([{ action: "sync", config: currentOn, hostname: "live-bound" }]); + expect(h.desktopActions).toEqual([]); + }); + + test("live-proxy ON→OFF uses the current OFF snapshot instead of rewriting files", async () => { + const staleOn = config({ hostname: "stale-host", fingerprint: "fp-on" }); + const currentOff = config({ grok: false, desktop: false, hostname: "fresh-host", fingerprint: "fp-off" }); + const h = harness(staleOn); + h.flip(currentOff); + await runLiveBranch(h, "live-bound"); + expect(h.grokActions).toEqual([{ action: "strip", config: currentOff }]); + expect(h.desktopActions).toEqual([{ action: "remove", fingerprint: "fp-off" }]); + }); + + test("spawned-proxy OFF→ON syncs from the current config, including hostname", async () => { + const staleOff = config({ grok: false, desktop: false, hostname: "stale-host", fingerprint: "fp-off" }); + const currentOn = config({ hostname: "fresh-host", fingerprint: "fp-on" }); + const h = harness(staleOff); + h.flip(currentOn); + await runSpawnedBranch(h); + expect(h.grokActions).toEqual([{ action: "sync", config: currentOn, hostname: "fresh-host" }]); + expect(h.desktopActions).toEqual([]); + }); + + test("spawned-proxy ON→OFF strips and removes from the current OFF snapshot", async () => { + const staleOn = config({ hostname: "stale-host", fingerprint: "fp-on" }); + const currentOff = config({ grok: false, desktop: false, hostname: "fresh-host", fingerprint: "fp-off" }); + const h = harness(staleOn); + h.flip(currentOff); + await runSpawnedBranch(h); + expect(h.grokActions).toEqual([{ action: "strip", config: currentOff }]); + expect(h.desktopActions).toEqual([{ action: "remove", fingerprint: "fp-off" }]); + }); +}); diff --git a/tests/grok-lifecycle.test.ts b/tests/grok-lifecycle.test.ts index b11e426e86..576b0f0a64 100644 --- a/tests/grok-lifecycle.test.ts +++ b/tests/grok-lifecycle.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { isServiceOwnershipError, ServiceOwnershipError } from "../src/service"; const CLI_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "index.ts"), "utf8"); +const ENSURE_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "ensure-desired-integrations.ts"), "utf8"); const DISPATCH_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "cli", "dispatch.ts"), "utf8"); const SERVICE_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "service.ts"), "utf8"); const MANAGEMENT_SOURCE = readFileSync(join(import.meta.dir, "..", "src", "server", "management-api.ts"), "utf8"); @@ -32,14 +33,66 @@ describe("Grok fence lifecycle wiring", () => { expect(grokSyncAt).toBeGreaterThan(registryCatchAt); }); - test("ensure passes the observed bind host on the live branch and the configured host after spawning", () => { + test("ensure passes the observed bind host on the live branch and the current host after spawning", () => { const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); const liveBranch = ensureFn.slice(0, ensureFn.indexOf("const pinPort")); const spawnBranch = ensureFn.slice(ensureFn.indexOf("const pinPort")); // live.hostname is what the proxy ACTUALLY bound; config.hostname may have drifted. expect(liveBranch).toContain("live.hostname ? { hostname: live.hostname }"); - expect(spawnBranch).toContain("config.hostname ? { hostname: config.hostname }"); + // Spawn must not reuse the pre-waitForProxy snapshot for hostname / desired state. + expect(spawnBranch).toContain("const current = loadConfig()"); + expect(spawnBranch).toContain("current.hostname ? { hostname: current.hostname }"); + expect(spawnBranch).not.toContain("config.hostname ? { hostname: config.hostname }"); + }); + + test("ensure gates Grok fence writes on the durable switch like start", () => { + const helper = sliceFn( + ENSURE_SOURCE, + "export async function ensureGrokFenceMatchesDesired(", + "export function ensureClaudeDesktopMatchesDesired(", + ); + const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); + + // The defect: ensure called syncGrokConfig unconditionally, so OFF lasted until + // the next dashboard update/restart path that landed in ensure. + expect(helper).toContain("shouldSyncGrokOnStart(config)"); + expect(helper).toContain("deps.stripGrokConfig()"); + expect(helper).toContain("deps.syncGrokConfig("); + expect(ENSURE_SOURCE).toContain('await import("../grok/sync")'); + expect(helper.indexOf("deps.loadConfig()")).toBeLessThan(helper.indexOf("shouldSyncGrokOnStart(config)")); + expect(ensureFn).toContain("ensureGrokFenceMatchesDesired("); + expect(ensureFn).not.toMatch(/await import\("\.\.\/grok\/sync"\)/); + }); + + test("ensure clears Claude Desktop residue when the durable switch is OFF", () => { + const helper = sliceFn( + ENSURE_SOURCE, + "export function ensureClaudeDesktopMatchesDesired(", + "Claude Desktop cleanup failed", + ); + const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); + expect(helper).toContain("claudeDesktopIntegrationEnabled(config)"); + expect(helper).toContain("deps.removeDesktop3pStandardPivot("); + expect(helper.indexOf("deps.loadConfig()")).toBeLessThan(helper.indexOf("claudeDesktopIntegrationEnabled(config)")); + expect(ensureFn).toContain("ensureClaudeDesktopMatchesDesired()"); + expect(ensureFn).not.toContain("ensureClaudeDesktopMatchesDesired(config)"); + }); + + test("both ensure branches re-read persisted config after the in-flight await window", () => { + const ensureFn = sliceFn(CLI_SOURCE, "async function handleEnsure(", "async function handleTrayProxyStart("); + const liveBranch = ensureFn.slice(0, ensureFn.indexOf("const pinPort")); + const spawnBranch = ensureFn.slice(ensureFn.indexOf("const pinPort")); + const liveAfterAwait = liveBranch.slice(liveBranch.indexOf("injectSystemEnv")); + const spawnAfterAwait = spawnBranch.slice(spawnBranch.indexOf("waitForProxy")); + expect(liveAfterAwait).toContain("const current = loadConfig()"); + expect(liveAfterAwait.indexOf("const current = loadConfig()")).toBeLessThan( + liveAfterAwait.indexOf("ensureGrokFenceMatchesDesired("), + ); + expect(spawnAfterAwait).toContain("const current = loadConfig()"); + expect(spawnAfterAwait.indexOf("const current = loadConfig()")).toBeLessThan( + spawnAfterAwait.indexOf("ensureGrokFenceMatchesDesired("), + ); }); test("handleStop gates shared teardown on ownership but still reverts system env", () => { diff --git a/tests/native-claude-desktop-toggle.test.ts b/tests/native-claude-desktop-toggle.test.ts index 587dfb6f52..130c567556 100644 --- a/tests/native-claude-desktop-toggle.test.ts +++ b/tests/native-claude-desktop-toggle.test.ts @@ -123,6 +123,64 @@ test("OFF on a missing or empty library is an idempotent no-op with no footprint expect(existsSync(join(empty, "_meta.json"))).toBe(false); }); +test("OFF removes an owned drifted gateway even without a saved fingerprint", async () => { + mkdirSync(library); + const id = "drifted-owned"; + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", + inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", + inferenceGatewayApiKey: "not-a-secret", + })); + + const result = await toggle(false); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ ok: true, changed: true, desiredEnabled: false, state: "absent" }); + expect(existsSync(join(library, `${id}.json`))).toBe(false); + expect(persistedIntent()).toBe(false); + + const status = await dispatch("/api/claude-desktop/status"); + const body = await status!.json() as { desiredEnabled: boolean; applied: boolean; stale: boolean; observedKind: string }; + expect(body).toMatchObject({ desiredEnabled: false, applied: false, stale: false }); + expect(body.observedKind).not.toBe("gateway_drifted"); + expect(body.observedKind).not.toBe("gateway_ours"); +}); + +test("status reports leftover owned drift as not stale when the durable switch is OFF", async () => { + mkdirSync(library); + const id = "drifted-owned"; + writeFileSync(join(library, "_meta.json"), JSON.stringify({ appliedId: id, entries: [{ id, name: "opencodex" }] })); + writeFileSync(join(library, `${id}.json`), JSON.stringify({ + inferenceProvider: "gateway", + inferenceCredentialKind: "static", + inferenceGatewayBaseUrl: "http://127.0.0.1:10100", + inferenceGatewayApiKey: "not-a-secret", + })); + + // Persist OFF without the native teardown path, so the leftover gateway stays selected. + expect(setIntegrationEnabled("claude-desktop", false).ok).toBe(true); + expect(persistedIntent()).toBe(false); + expect(existsSync(join(library, `${id}.json`))).toBe(true); + + const status = await dispatch("/api/claude-desktop/status"); + const body = await status!.json() as { + desiredEnabled: boolean; + applied: boolean; + stale: boolean; + drift: boolean; + driftReason: string | null; + observedKind: string; + }; + expect(body.observedKind).toBe("gateway_drifted"); + expect(body).toMatchObject({ + desiredEnabled: false, + stale: false, + drift: true, + driftReason: "desired_off_gateway_selected", + }); +}); + test("post-commit unsafe and incomplete refusals disclose desired OFF without contents", async () => { writeFileSync(join(root, "config.json"), JSON.stringify(config())); writeFileSync(join(root, "metadata-marker"), "");