From 0e7ed63a6aa2065a7841a5668ee36bdde94b7df1 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 22 Sep 2026 18:51:54 +0900 Subject: [PATCH] fix(claude): persist committed gateway state before old-mode cleanup --- .../260922_native_tray_release/040_release.md | 92 ++++++++++++++++++- .../src/content/docs/guides/claude-code.md | 3 +- src/claude/desktop-gateway-state.ts | 41 +++++++++ src/cli/claude-desktop.ts | 25 +++-- .../management/agent-settings-routes.ts | 44 ++------- .../management/native-integration-routes.ts | 8 +- structure/clients/claude-desktop.md | 6 +- .../claude-desktop-first-party.test.ts | 40 ++++++++ 8 files changed, 211 insertions(+), 48 deletions(-) create mode 100644 src/claude/desktop-gateway-state.ts diff --git a/devlog/_plan/260922_native_tray_release/040_release.md b/devlog/_plan/260922_native_tray_release/040_release.md index 36c6c825e3f..f393479d6f3 100644 --- a/devlog/_plan/260922_native_tray_release/040_release.md +++ b/devlog/_plan/260922_native_tray_release/040_release.md @@ -9,13 +9,101 @@ Depends on wp3. User explicitly selected both main and preview publication. - If dev does not outrank the intended release, use the repository's dev-version-bump PR flow before publication. MODIFY only version-bearing files selected by that canonical flow, no ad-hoc drift. - Promote dev through PRs to main/preview following required review/branch policy. No direct protected-branch push or force push. Keep objections and security review separate; do not fabricate independent approval. - Execute the canonical release command/workflow with exact expected SHA, branch, version and dist-tag. Stable and preview runs are serialized. A failed or pending workflow is not published success; reconcile before retrying. -- VERIFY GitHub release/tag and artifact inventory/checksums/signatures/updater manifest, npm versions/dist-tags/gitHead and required exact-head CI. Install the final macOS artifact locally, preserving backup; verify native popup interaction, bundled CLI resolve and proxy ownership/health. Record running application path/hash and source/build identity. +- VERIFY GitHub release/tag and artifact inventory/checksums/signatures/updater manifest, npm versions/dist-tags/gitHead and required exact-head CI. Use hosted installed-artifact validation when runners exist. The authorized local app update preserves backup and runtime ownership, but local interaction, CLI and health probes remain NOT RUN under the latest instruction. Record artifact identity without claiming local execution proof. - MODIFY this unit's `041_release_receipts.md`, then archive the unit to `devlog/_fin/` only once all cycles are terminal. ## Acceptance and rollback -Both channels have reachable verified artifacts at their recorded commits; native popup is installed and usable. Keep the prior application backup and prior published version/digest so local rollback is reversible. Never republish the same version to repair a bad artifact; use repository release policy. If a protected promotion requires an independent maintainer action not available to this session, stop that publication step with the exact blocker while completing all independent preparation; no bypass inferred from beta status. +Both channels have reachable verified artifacts at their recorded commits; the authorized app update is completed and local interaction remains explicitly unverified under the no-local-tests instruction. Keep the prior application backup and prior published version/digest so local rollback is reversible. Never republish the same version to repair a bad artifact; use repository release policy. If a protected promotion requires an independent maintainer action not available to this session, stop that publication step with the exact blocker while completing all independent preparation; no bypass inferred from beta status. ## Publication observation during wp3 On 2026-09-22 the official npm registry reports version2.60.0 exists with gitHead7c625fc9755c9824653ab944190e243091a2c85c, matching origin/main and the published GitHub v2.60.0 release. However the live npm tags are latest=2.59.0 and preview=2.55.0-preview.20260914. This was re-read with the explicit official registry and prefer-online; no dist-tag mutation was performed. Both requested channel deliveries must verify the actual final registry tags in addition to GitHub assets and version existence. Do not republish2.60.0 or silently count it as the current latest tag. + +## Executable wp4 plan — 2026-09-22 +The native tray and regression candidate is verified at 3d64bd3040b2da7953962da3c05be14f31991e56. +This phase integrates that exact candidate and publishes independently derived preview and stable +artifacts. Release notes and receipts distinguish source review, hosted execution and installation. + +Loop: satisfy-spec, C4 release operations; trigger: explicit both-channel delivery and subsequent +dev admin-merge authorization. Goal: both channels published with verified artifacts and the +authorized app update. Non-goals: no local tests, typechecks, builds or QA probes; no live runtime +restart, credential changes or protection-rule mutation. Main executes, Sol reviews read-only. +No user-imposed time/token budget. Memory artifact: this unit and its release receipts, plus +ignored operational receipts in .tmp/native-tray-design. Success ends only after both channels +are verified; pending or partial publication remains unfinished. Escalate only actual unavailable +promotion authority, signing credentials or installation access, after completing independent work. + +### Dependency order and file map +1. D1: Re-read PR #5490 head, all exact-head hosted results, automated reviews and maintainer + objections. Explicit owner-authorized admin merge targets dev only. It is not an independent + approval. Source/security review is recorded separately. Keep this plan amendment uncommitted + until the delivery metadata commit; the remote PR head remains the verified candidate. +2. D2: Fetch the resulting dev merge SHA and freeze its immutable 2.61.0 RC branch/tree before + changing the dev version. Confirm the merge contains the reviewed changes without unexpected + product differences. Keep the source candidate pinned if dev advances. +3. D3: Dispatch dev-version-bump.yml from main with intended-version=2.61.0, mode=pre-move; + review and merge its package-only PR after hosted checks. Confirm dev is 2.62.0. +4. D4-D6: Prepare preview through an ordinary promotion PR based on current preview plus the + frozen RC. Use actual KST publication date in 2.61.0-preview.YYYYMMDD, adding an unused ordinal + when necessary. MODIFY package.json, desktop/src-tauri/tauri.conf.json, Cargo.toml and Cargo.lock + consistently; apart from release metadata, retain the frozen product tree. Observe final + promotion-SHA push CI/service success. Dispatch release.yml dry-run then publication, serialized, + tag=preview and exact expected-sha. Verify embedded desktop version in hosted artifacts and + npm version/dist-tag/gitHead/integrity/provenance, tag/release target, asset set/checksums and + updater signatures. Preview uses its tag-specific manifest; stable updater discovery is unchanged. +5. D7-D9: Prepare main independently from the same RC, never from preview or post-bump dev. + All four version authorities remain 2.61.0. Repeat final-SHA push CI/service, canonical dry-run + and publication with tag=latest. Verify both dist-tags, all assets and the stable latest manifest. + Use hosted installed-artifact validation where configured. Local update is authorized but local + execution checks remain NOT RUN under the latest prohibition; preserve the prior app backup, + user configuration and running proxy. Do not represent installation alone as interaction proof. +6. D10: If publication is partial, inspect the actual registry/tag/release state. Resume only an + acknowledged npm publication at the identical version/SHA using the canonical source-bound + resume path. Missing/mismatched provenance or signing evidence refuses completion. +7. D11: Promotions retain current MAINTAINERS.md rules. Record any explicitly authorized owner + override as an override, never an independent approving review. Do not weaken rulesets. +8. UPDATE 041_release_receipts.md with exact SHAs, versions, URLs and observable limitations; + archive the unit only after both channels and acceptance criteria are terminal. + +### Reachable verification and failures +- GitHub gh run view/watch reads exact head/status/jobs: the wp3 PR, all-platform and service runs + completed successfully; receipt command exit0 was observed at the clean candidate. These are + read-only hosted-result queries, not local tests. +- New release/promotion runs are NOT RUN yet. Their workflow definitions read checkout/version, + expected-sha, CI/service history, signing inputs, generated bundle bytes and registry state. + Actual triggers are workflow_dispatch on the selected protected branch with a full expected-sha. +- Branch movement must fail the dispatch identity check; existing consumed versions must fail + fresh publication; missing signing inputs or invalid assets must fail before publish; registry + source mismatch must fail resume. Do not activate destructive failures against public versions. +- No local verification command is implied by this plan. Windows/macOS skipped jobs, cancellation, + old-commit runs and review comments are not substituted for current execution evidence. + +### Architect consultation +Architect: Newton (01a0c744-11eb-7dd2-9af9-37d2b735b545), read-only Sol. Proposal D1-D11 accepted. +Main amendment to D9: latest no-local-tests instruction excludes local probes; use hosted artifact +checks and report local execution unverified. Promotion authority remains explicit per D11. +Same-architect reflection and independent audit are recorded before execution. + +### D0 — integration-review correction before D1 +GitHub Codex review at the verified head reported comment4070117466: a committed gateway write +followed by unreadable first-party settings returns before persisting the gateway mode and apply +fingerprint. Accepted for correction in this integration phase, without invalidating the prior +wp3 evidence at its recorded head. MODIFY the CLI apply path and both management paths to persist +committed gateway bookkeeping before reporting cleanup failure, while keeping the failed cleanup +visible and preserving any separate bookkeeping warning. Add focused cases to the existing +Claude Desktop first-party suite: start from first-party, make settings unreadable, apply gateway, +observe failure/partial cleanup and persisted gateway mode/fingerprint, and confirm a subsequent +default apply selects gateway. Include API, native-toggle and CLI paths as applicable. Update the +owning Desktop contract. Require Sol read-only review and fresh exact-head hosted CI before merge; +the previous head's green result does not certify this correction. No local tests are allowed. + +Architect reflection disposition: D0-D5 and D7-D11 aligned. D6 amendment accepted: preview +verification explicitly requires GitHub prerelease=true and npm latest unchanged from the +recorded pre-preview value. Main records that before stable publication changes latest. +Final same-architect reflection: Newton returned ALIGNED for D0-D11 after the D6 amendment; +no remaining architecture gap. Independent A audit follows. + +Latest owner steering: Latest owner instruction ci 걍 무시하고 머지해 executed: PR5490 admin squash merged to dev6c2f7676dcedba21bdbacf4fb84a7b2c286d1ee6 at2026-09-22T09:24:09Z. Priorcandidate3d64 hadPR/allplatform/serviceSUCCESS. D1 now precedesD0 by explicituseroverride; no fabricated B order. D0reviewfinding gatewaypartialbookkeeping remains narrowfollowup beforefreezeRC/publish. Bothpreview+stabledeployment remainsauthorized. No-local-tests andno-verify unchanged. + +Independent A audit: Volta NEAR-PASS. Both text gaps are folded: D0 explicitly requires credential-boundary security review under MAINTAINERS.md in addition to ordinary source review; the original local-verification wording above now matches the latest no-local-execution restriction. New promotion drafts #5510/#5511 are provisional and will receive the corrected RC. No release has been published. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 24362cf15b3..c1b06b27f82 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -177,7 +177,8 @@ then removes the other mode's configuration (only values OpenCodex wrote — a f `NODE_EXTRA_CA_CERTS`, for example a corporate proxy, is never overwritten and the apply is refused instead). A failed replacement preserves the previous connection. If retiring the old configuration fails after the replacement was written, the command reports incomplete cleanup; -resolve that error before restarting Desktop. Fully quit and reopen Desktop after a successful switch. `ocx ensure` refreshes a stale +resolve that error before restarting Desktop. A committed gateway keeps its saved mode and profile +marker even when first-party settings cleanup fails. Fully quit and reopen Desktop after a successful switch. `ocx ensure` refreshes a stale first-party env when the integration is ON and removes it when OFF. Set `claudeCode.intercept.enabled: false` to disable the proxy entirely; first-party then cannot be applied and an implicit apply falls back to gateway. On a connected client the proxy runs on the diff --git a/src/claude/desktop-gateway-state.ts b/src/claude/desktop-gateway-state.ts new file mode 100644 index 00000000000..ee799004af0 --- /dev/null +++ b/src/claude/desktop-gateway-state.ts @@ -0,0 +1,41 @@ +import { mutatePersistedConfig } from "../config"; +import type { OcxConfig } from "../types"; +import { emptyDesktopProfile, type DesktopProfile } from "./desktop-profile"; + +/** Record the bytes already committed by the gateway writer, before cleanup of + * the previous mode. Mode and fingerprint belong to one config transaction. */ +export function recordCommittedDesktopGateway( + config: Pick, + profile: DesktopProfile | undefined, + fingerprint: string | undefined, + appliedAt: string, +): void { + const { appliedFingerprint: _oldFingerprint, appliedAt: _oldTime, ...base } = profile ?? emptyDesktopProfile(); + config.claudeCode = { + ...config.claudeCode, + desktopMode: "gateway", + desktopProfile: { + ...structuredClone(base), + ...(fingerprint ? { appliedFingerprint: fingerprint, appliedAt } : {}), + }, + }; +} + +export function persistCommittedDesktopGateway( + snapshot: OcxConfig, + profile: DesktopProfile | undefined, + fingerprint: string | undefined, +): { ok: true } | { ok: false; reason: "missing" | "invalid" | "conflict" | "unavailable" } { + const appliedAt = new Date().toISOString(); + try { + const outcome = mutatePersistedConfig(current => { + recordCommittedDesktopGateway(current, profile, fingerprint, appliedAt); + return { changed: true, value: true }; + }); + if (outcome.status === "unavailable") return { ok: false, reason: outcome.reason }; + recordCommittedDesktopGateway(snapshot, profile, fingerprint, appliedAt); + return { ok: true }; + } catch { + return { ok: false, reason: "unavailable" }; + } +} diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts index 3de86a93c06..5baaec6f995 100644 --- a/src/cli/claude-desktop.ts +++ b/src/cli/claude-desktop.ts @@ -1,3 +1,4 @@ +import { recordCommittedDesktopGateway } from "../claude/desktop-gateway-state"; import { readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { loadConfig, mutatePersistedConfig, withConfigMutationLockSync } from "../config"; @@ -71,6 +72,7 @@ function saveLocalDesktopProfile( expectedProfile: DesktopProfile | undefined, expectedConnection: ClientConnectionState, deps: ApplyProfileDeps, + gatewayWrite?: { fingerprint?: string }, ): void { withClientLifecycleSync(() => { const outcome = mutatePersistedConfig(current => { @@ -88,6 +90,10 @@ function saveLocalDesktopProfile( if (JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(expectedProfile)) { throw new Error("desktop_profile_changed"); } + if (gatewayWrite) { + recordCommittedDesktopGateway(current, profile, gatewayWrite.fingerprint, new Date().toISOString()); + return { changed: true, value: undefined }; + } const changed = JSON.stringify(current.claudeCode?.desktopProfile) !== JSON.stringify(profile); if (changed) current.claudeCode = { ...(current.claudeCode ?? {}), desktopProfile: structuredClone(profile) }; return { changed, value: undefined }; @@ -285,12 +291,13 @@ export async function applyDesktop( if (target.kind === "first-party") return applyFirstPartyDesktop(deps); const result = await applyProfile(profile, target.mode, deps); if (!result.ok) return result; - // Keep the current first-party connection until gateway application succeeds. + const modeSaved = saveDesktopMode("gateway", deps); + const warning = [result.warning, modeSaved ? "" : "desktop mode marker was not saved"].filter(Boolean).join(" "); + // The gateway mode is committed before retiring first-party settings. const removed = removeDesktopFirstParty(); - if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable", warning: "gateway applied; first-party cleanup remains incomplete" }; - if (result.ok && !saveDesktopMode("gateway", deps)) { - return { ...result, warning: [result.warning, "desktop mode marker was not saved"].filter(Boolean).join(" ") }; - } + if (!removed.ok) return { ok: false, path: removed.path, reason: "first_party_settings_unreadable", + warning: ["gateway applied; first-party cleanup remains incomplete", warning].filter(Boolean).join(" ") }; + if (warning) return { ...result, warning }; return result; } @@ -364,8 +371,13 @@ export async function applyProfile( nativeContextLimits(config), deps.lifecycleLockDeps, ); + let stateWarning: string | undefined; + if (result.written) { + try { saveLocalDesktopProfile(state.profile, state.profile, connection, deps, { fingerprint: result.fingerprint }); } + catch { stateWarning = "gateway applied but its committed mode/profile state was not saved"; } + } const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)(); - const warning = result.written ? claudeDesktopPolicyWarning(policyState) : undefined; + const warning = [result.written ? claudeDesktopPolicyWarning(policyState) : undefined, stateWarning].filter(Boolean).join(" "); return { ok: result.written, path: result.path, @@ -393,6 +405,7 @@ export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProf const result = await applyDesktop(undefined, target, deps); if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); + if (result.warning) console.warn(result.warning); if (result.reason?.startsWith("gateway_")) { console.error("The gateway profile could not be removed safely, so first-party mode was not applied. Turn the integration off (dashboard toggle) and retry, or keep gateway with `ocx claude desktop apply --gateway`."); } else if (result.reason === "foreign_env") { diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 607f364348a..132f59bc055 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,3 +1,4 @@ +import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; @@ -1155,51 +1156,26 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise nativeContextLimits(latest), ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); - // The new gateway is committed; retire the previous first-party env now. + const committed = persistCommittedDesktopGateway(config, state.profile, result.fingerprint); + const modeWarning = committed.ok ? undefined : `Gateway applied, but its mode/profile state was not saved (${committed.reason}).`; + // The durable marker describes the committed gateway even if old-mode cleanup fails. const firstPartyRemoved = removeDesktopFirstParty(); if (!firstPartyRemoved.ok) { return jsonResponse({ - error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); the first-party proxy env could not be removed after applying gateway mode.`, - code: "claude_desktop_first_party_refused", - reason: firstPartyRemoved.reason, + error: `Claude Code settings could not be parsed (${firstPartyRemoved.path}); first-party cleanup remains incomplete after gateway apply.`, + code: "claude_desktop_first_party_refused", reason: firstPartyRemoved.reason, + applied: true, saved: committed.ok, mode: "gateway", path: result.path, + ...(modeWarning ? { warning: modeWarning } : {}), }, 500); } - const modeSaved = await persistDesktopModeField(config, "gateway"); - const modeWarning = modeSaved.ok ? undefined : `Claude Desktop was applied, but the gateway mode marker was not saved (${modeSaved.reason}).`; const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../../claude/desktop-policy"); - const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ - platform: deps.platform ?? process.platform, - }); + const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)({ platform: deps.platform ?? process.platform }); const policyWarning = claudeDesktopPolicyWarning(policyState); - // Persist applied fingerprint + timestamp so GUI can show saved-vs-applied state. - if (result.fingerprint) { - // The Desktop write already landed, so a failed bookkeeping save is not - // an apply failure: report the miss instead of claiming a clean apply. - const marked = persistDesktopProfileField(config, { - ...state.profile, - appliedFingerprint: result.fingerprint, - appliedAt: new Date().toISOString(), - }); - if (!marked.ok) { - return jsonResponse({ - ok: true, - applied: true, - saved: false, - path: result.path, - fingerprint: result.fingerprint, - warning: [ - `Claude Desktop was applied, but the applied marker was not saved (${marked.reason}).`, - modeWarning, - policyWarning, - ].filter(Boolean).join(" "), - }); - } - } const warning = [modeWarning, policyWarning].filter(Boolean).join(" "); return jsonResponse({ ok: true, mode: "gateway", - saved: modeSaved.ok, + saved: committed.ok, applied: true, path: result.path, fingerprint: result.fingerprint, diff --git a/src/server/management/native-integration-routes.ts b/src/server/management/native-integration-routes.ts index bdd2ff3fb93..736bdf9aa0b 100644 --- a/src/server/management/native-integration-routes.ts +++ b/src/server/management/native-integration-routes.ts @@ -1,3 +1,4 @@ +import { persistCommittedDesktopGateway } from "../../claude/desktop-gateway-state"; /** * Toggle routes for the integrations that are NOT file-merged clients. * @@ -754,14 +755,15 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise { + expect(applyDesktopFirstParty(config()).ok).toBe(true); + const initial = surface === "native" + ? config({ claudeCode: { intercept: { enabled: false } } }) + : config({ claudeCode: { desktopMode: "first-party" } }); + writeFileSync(join(root, "config.json"), JSON.stringify(initial)); + writeFileSync(join(claudeDir, "settings.json"), "{ malformed first-party settings"); + if (surface === "cli") { + const result = await applyDesktop(undefined, { kind: "gateway", mode: "static" }, { + findLiveProxyImpl: async () => null, + }); + expect(result).toMatchObject({ ok: false, reason: "first_party_settings_unreadable" }); + expect(result.warning).toContain("gateway applied"); + } else { + const result = surface === "api" + ? await dispatch("/api/claude-desktop/apply", { method: "POST", body: JSON.stringify({ mode: "gateway" }) }, initial) + : await dispatch("/api/native-integrations/claude-desktop", { method: "PUT", body: JSON.stringify({ enabled: true }) }, initial); + expect(result.status).toBe(500); + if (surface === "api") expect(result.body).toMatchObject({ applied: true, saved: true, mode: "gateway" }); + else expect(result.body.message).toContain("Gateway applied"); + } + const saved = JSON.parse(readFileSync(join(root, "config.json"), "utf8")) as OcxConfig; + const fingerprint = saved.claudeCode?.desktopProfile?.appliedFingerprint; + expect(saved.claudeCode?.desktopMode).toBe("gateway"); + if (surface !== "cli") { + expect(initial.claudeCode?.desktopMode).toBe("gateway"); + expect(initial.claudeCode?.desktopProfile?.appliedFingerprint).toBe(fingerprint); + } + expect(fingerprint).toBeTruthy(); + expect(inspectDesktop3pConfigLibrary({ appliedFingerprint: fingerprint })).toMatchObject({ kind: "gateway_ours", fingerprint }); + expect(saved.claudeCode?.desktopProfile?.appliedAt).toEqual(expect.any(String)); + expect(resolveClaudeDesktopApplyMode({ ...saved, claudeCode: { ...saved.claudeCode, intercept: { enabled: true } } })).toBe("gateway"); + expect(readFileSync(join(claudeDir, "settings.json"), "utf8")).toBe("{ malformed first-party settings"); + const status = await dispatch("/api/claude-desktop/status", {}, saved); + expect(status.body.mode).toBe("gateway"); + expect(status.body.observedKind).toBe("gateway_ours"); + }); +}