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 gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ export const de: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1335,6 +1335,7 @@ export const fr: Record<TKey, string> = {
"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é",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,7 @@ export const ja: Record<TKey, string> = {
"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 プロファイルが変更されました",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ export const ko: Record<TKey, string> = {
"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 프로필이 변경되었습니다",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,7 @@ export const ru: Record<TKey, string> = {
"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 был изменён",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,7 @@ export const tr: Record<TKey, string> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1871,6 +1871,7 @@ export const zhTW: Record<TKey, string> = {
"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 設定檔已變更",
Expand Down
1 change: 1 addition & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,7 @@ export const zh: Record<TKey, string> = {
"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 配置已更改",
Expand Down
4 changes: 4 additions & 0 deletions gui/src/pages/integrations/integration-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion gui/src/pages/integrations/overview-clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export interface ClaudeDesktopPayload {
observedKind?: string;
applied?: boolean;
stale?: boolean;
drift?: boolean;
driftReason?: string | null;
activeProfile?: boolean | null;
appliedAt?: string | null;
}
Expand Down Expand Up @@ -305,14 +307,38 @@ 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,
state: "absent",
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;
Expand Down
45 changes: 45 additions & 0 deletions gui/tests/integrations-overview-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
21 changes: 15 additions & 6 deletions src/claude/desktop-3p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand All @@ -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)) {
Expand All @@ -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)
Expand All @@ -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");
Expand Down
129 changes: 129 additions & 0 deletions src/cli/ensure-desired-integrations.ts
Original file line number Diff line number Diff line change
@@ -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<GrokInjectResult>;
removeDesktop3pStandardPivot: typeof removeDesktop3pStandardPivot;
log?: (message: string) => void;
error?: (message: string) => void;
}

async function defaultSyncGrokConfig(
port: number,
config: OcxConfig,
opts: { hostname?: string } = {},
): Promise<GrokInjectResult> {
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<void> {
const config = deps.loadConfig();
const { log, error } = io(deps);
if (!shouldSyncGrokOnStart(config)) {
try {
const grok = deps.stripGrokConfig();
Comment on lines +81 to +83

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 Update the Grok guide for OFF reconciliation

This new OFF branch changes ocx ensure from always replacing the managed block to stripping it when the durable switch is disabled, but docs-site/src/content/docs/guides/grok-build.md:13-14,31-32 and every translated copy still state that ensure writes/replaces the block. Update the canonical guide and its translations so operators are not told behavior that now directly contradicts the implementation.

AGENTS.md reference: src/AGENTS.md:L28-L28

Useful? React with 👍 / 👎.

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(

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 Recheck Grok intent after the asynchronous catalog fetch

When Grok is ON at line 79, syncGrokConfig awaits provider discovery (src/grok/sync.ts:37) before synchronously injecting the fence, but this helper never reloads desired state after that await. If the dashboard turns Grok OFF during discovery, the toggle strips the fence and this stale ensure operation subsequently writes it back, again violating the durable OFF setting. Recheck the current intent immediately before the writer, as the management toggle does, and cover the deferred-fetch window rather than only flipping state before entering this helper.

Useful? React with 👍 / 👎.

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}.`);
}
}
Loading
Loading