feat(connect): one-click undo that restores the pre-connect client config (Spec 078 US3)#804
Conversation
…pec 078 US3)
The connect step's change is now fully reversible from the surface that made
it: the wizard row and the standalone Connect modal gain a one-click Undo next
to the backup path they surface. Undo restores the client config byte-for-byte
from the backup the connect took — the only revert that can bring back a
pre-existing same-named entry a force-connect overwrote (surgical disconnect
cannot) — or removes the file when the connect created it. Before reverting,
the change to be undone is shown again (FR-009), and the restore refuses with
a clear 409 when the file changed since the connect, never clobbering later
edits.
## Changes
- internal/connect: Service.Undo — byte-identical restore from the connect
backup; drift check replays the connect write from the backup and compares
bytes (refuses on any mismatch); no-prior-file case deletes the created
file; own safety backup before every mutation; backup paths validated to
<config>.bak.* of that client only; TCC denials map to *AccessError
- internal/connect: backup names are now collision-proof — same-second
operations get a -1/-2 numeric suffix (O_EXCL, never overwrite), fixing the
case where connect+disconnect within one second destroyed the very backup a
later undo needs; existing <config>.bak.<ts> shape kept as prefix
- httpapi: POST /api/v1/connect/{client}/undo — 200 restored|deleted,
409 conflict on drift, 404 missing backup/unknown client, 403 + remediation
on macOS App-Data denial (mirrors the other per-client connect routes)
- Web UI: wizard ClientRow Undo affordance on the session backup line +
revert-preview panel (shows the entry that will be reverted, Undo/Keep);
same session-scoped affordance on the ConnectModal result area
- Telemetry: connect_step completion is deliberately NOT retracted on undo —
the funnel event records that the step was completed; undo reverts the file
change, not the funnel transition (FR-016)
- Docs: rest-api.md (preview + undo endpoints, backup naming/retention),
setup.md wizard callout; OpenAPI regenerated
## Testing
- Go: byte-identical restore (JSON + TOML), drift refusal (with and without a
prior file), no-prior-file delete, missing/foreign backup, deterministic
same-second backup collision; httptest for the endpoint incl. 409/404/403
- vitest: wizard + modal undo flows (revert panel first, honest refusal,
session scoping) — 256 tests green
- Live smoke on :18096 (scratch HOME): force-connect over a user-owned
mcpproxy entry, undo via curl, diff byte-for-byte; drift edit → HTTP 409,
file untouched
… preview/undo also read configs
The per-client status doc claimed GET /connect/{client} is the only Connect
endpoint that opens a client config file; the preview and undo routes
(documented in the same section) also read it on demand, so the 'sole/only
place' wording contradicted the surrounding text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying mcpproxy-docs with
|
| Latest commit: |
a56dcf6
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://286c138c.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://feat-078-undo.mcpproxy-docs.pages.dev |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 28642517636 --repo smart-mcp-proxy/mcpproxy-go
|
The undo backup-path guard was prefix-only: a path like "<config>.bak.x/../../secret.json" kept the "<config>.bak." prefix yet escaped the config directory, letting undo read (and, in a crafted case, restore) an arbitrary file — defeating the documented "must not become an arbitrary-file-restore primitive" invariant. Anchor validation on the cleaned path's parent directory and basename instead, both traversal-proof. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final Codex review (post-merge state @
|
…ved server-side CodeQL flagged path injection at the connect read() seam: the undo request's user-supplied backup_path flowed into os.ReadFile. Rather than sanitize a client-controlled path, stop trusting one entirely. The request now carries only backup_name — the bare filename of the backup the connect returned. Undo resolves the full path itself by joining it with THIS client's own config directory (derived from the client registry, never from the request), after rejecting anything whose filepath.Base differs from the input and requiring the strict "<config-basename>.bak." prefix. The user input can no longer contribute a directory component, so traversal is impossible by construction and the taint path breaks at the Base()==input guard + constant-dir join. - API: UndoConnectRequest.BackupPath -> BackupName (json backup_name); server resolves + validates; unknown basename still 404, path-shaped name -> 400. - Frontend: api.undoConnectClient sends filepath.Base (strips / and \) so both wizard and ConnectModal call sites emit a bare name; new connect-undo-api spec asserts the wire payload. - Docs + OpenAPI regenerated for backup_name. - Tests: absolute path rejected, separators-in-name rejected, unknown name 404, path-shaped name -> 400 at the HTTP boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CodeQL-driven hardening — undo now takes a validated basename (
|
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Motivation
Spec 078 US3 (specs/078-connect-trust-preview) — roadmap task
connect-trust-undo, the last piece of theconnect-trustepic ("Connect step trust: preview, visible backup, one-click undo"). Telemetry shows 72.4% of wizard-engaged users skip the connect step; completers retain ~50% at two weeks vs 6% for non-engaged. US1 (#802) previews the exact change and US2 (#799) surfaces the backup path — this PR closes the loop by making the connect fully reversible from the same surface that made it.Implementation
internal/connect/undo.go— newService.Undo(clientID, serverName, backupPath):backupPath != "": restores the client config byte-for-byte from the backup the preceding connect took — the only revert that can bring back a pre-existing same-named entry a force-connect overwrote (surgical disconnect cannot).backupPath == "": the connect created the file; undo deletes it, restoring the pre-connect "no file" state.replayConnectWritereconstructs, from the backup bytes, the exact bytes the connect wrote (samebuildServerEntry+ same JSON/TOML marshal, incl. OpenCode adopted-name normalization) and requires the CURRENT file to be byte-identical; any mismatch →Success=false,Action="conflict"(HTTP 409), file untouched. This intentionally also refuses when listen address / API key /require_mcp_authchanged since the connect.backup_path); validatesbackupPathis a<config>.bak.*sibling of that client's config (undo is not an arbitrary-file-restore primitive); TCC denials map through the existingasAccessError→ 403 + remediation.internal/connect/backup.go— backup names are now collision-proof: same-second operations get a-1/-2numeric suffix via anO_EXCLloop (never overwrite). Fixes the case where connect+disconnect within one second destroyed the very backup a later undo needs. Existing<config>.bak.<ts>shape kept as prefix.internal/httpapi—POST /api/v1/connect/{client}/undo: 200restored|deleted, 409 conflict on drift, 404 missing backup / unknown client, 403 + remediation on macOS App-Data denial (mirrors the other per-client connect routes).ClientRowUndo affordance on the session backup line + revert-preview panel (shows the entry that will be reverted before touching anything, FR-009: Undo/Keep); same session-scoped affordance on theConnectModalresult area. The affordance is deliberately session-scoped (in-memory last-connect), not persisted across reloads.connect_stepcompletion is deliberately NOT retracted on undo: the funnel event records that the step was completed; undo reverts the file change, not the funnel transition (FR-016).Codex review outcome
One full-diff Codex round (gpt-5.5, read-only,
origin/main..HEAD; focus: path-validation bypass, JSON/TOML replay fidelity, error mapping, hot-reload races, spec violations, test gaps). Verdict: REQUEST_CHANGES with two High findings — both rebutted (rationale below); no code changes resulted. Flagging them here explicitly so the merger can weigh the rebuttals:../traversal or a symlink named<config>.bak.<ts>" — Rebutted. The restore destination is always this client's own config path, never caller-controlled. The restore source must additionally pass the byte-exact drift replay (replayConnectWrite(source) == current file), so the only bytes undo can ever write are the semantically-determined pre-connect state of that config — a foreign file that satisfies the replay is content-equivalent to the legitimate backup. And the caller is already an authenticated localhost API client holding the key that can call connect/disconnect (i.e., mutate the same file) directly. Lexicalfilepath.Clean+Lstatregular-file hardening noted as a follow-up (out of scope below).undoConnectClientresolving{success:false}while the real wrapper throws on 409 — verified the components'catchpath surfaces the server's conflict message identically, so the UI flow is covered end-to-end by the live smoke; traversal/symlink and concurrency unit tests deferred with the hardening follow-up.LIVE verification
All 9 mandated live checks passed against a real built binary (isolated
--config/--data-dir, scratchHOME=/tmp/vf078u, port:18096):make buildexit 0 (v0.47.0-rc.2, commit8b5a7f50in ldflags);/readyz= 200.mcpproxyentry in a seeded.claude.json, then undo viacurl POST /api/v1/connect/claude-code/undowith the connect'sbackup_path→Action="restored", restored file byte-for-byte identical (diffclean) to the pre-connect original, including the overwritten same-named entry.Action="conflict", file untouched.backup_path→Action="deleted", file removed, safety copy left behind.httptestfor the endpoint incl. 409/404/403.Docs updated
docs/api/rest-api.md— preview + undo endpoints, backup naming/retention; also removed the now-stale claim thatGET /connect/{client}is the sole Connect endpoint that opens a client config (preview and undo also read it on demand — the wording contradicted the surrounding section).docs/setup.md— wizard callout.oas/swagger.yaml/oas/docs.go— regenerated for the new endpoint.Out of scope
filepath.Clean, same-dir +Lstatregular-file check) from Codex finding 1.🤖 Generated with Claude Code