Skip to content

feat(macos): native Connect Client form — preview-first, token-guarded (spec 091) - #941

Open
Dumbris wants to merge 40 commits into
mainfrom
091-connect-client-form
Open

feat(macos): native Connect Client form — preview-first, token-guarded (spec 091)#941
Dumbris wants to merge 40 commits into
mainfrom
091-connect-client-form

Conversation

@Dumbris

@Dumbris Dumbris commented Jul 31, 2026

Copy link
Copy Markdown
Member

What

A native tray path for connecting AI clients (Claude Code, Cursor, Windsurf, VS Code, Codex, Gemini, OpenCode, Claude Desktop) — "Connect Client…" menu item + SwiftUI form replacing the Web-UI-only flow and the legacy preview-less dashboard sheet:

  • TCC-safe list: stat-only aggregate (config present / no config / unsupported) — zero config-content reads until the user selects a client.
  • Preview before write, structurally: the Connect control cannot exist before a preview renders — pending entry, config path, a whitelist-by-construction summary of the entry being replaced (names/types/query+userinfo-stripped endpoint, header/env names only — no secret value can appear), backup consequences, credential-embedding notice, and the core's refusal (e.g. OpenCode without a config) surfaced up front.
  • Drift-proof writes: previews carry a keyed HMAC precondition token over the raw pre-write state (deterministic, single-resolution adoption lookup) and the pending entry; any drift — external edits (even ones the summary hides), file appearance/disappearance, proxy-side credential/address changes — yields a discriminated 409 and an automatic re-preview. No silent overwrites: force never rescues a stale token.
  • Session-scoped Undo (restores the backup / removes a created file) and confirmed Disconnect.
  • Strict-socket mutations: writes fail closed if the admin socket disappears mid-session, never silently ride TCP.

Backend deltas are additive and back-compatible (absent token = legacy behavior; Web UI unchanged): 3 new preview fields, optional precondition_token on the write, typed 409 discriminator, plus a SC-006 socket-to-gated-route e2e test.

Spec & reviews

Full speckit artifacts in specs/091-connect-client-form/ — spec approved after 4 Codex rounds; plan/tasks/implementation reviewed by an internal adversarial multi-agent panel with refute-verification (Codex quota-locked and Gemini CLI sunset mid-pipeline; 12 confirmed code findings all fixed + closure-verified, incl. a nondeterministic adoption-lookup class proven empirically).

Testing

  • Swift: 561 → 604 tests, 0 failures (state-machine invariants, decode fallbacks for pre-091 cores, in-flight/interleaving races, window-lifecycle teardown probed against real AppKit sheets, mutation-verified poll cancellation).
  • Go: internal/connect + internal/httpapi green incl. no-raw-secrets suites (rotated keys, bearer/env secrets, ?apikey=, user:pass@); full ./internal/... green; linter 0 issues; make swagger-verify green.
  • New docs: docs/features/connect-clients.md + refreshed REST reference.
  • Outstanding (release-blocking): manual visual protocol (specs/091-connect-client-form/verification/manual-protocol.md) — NOT RUN, needs a live app run.

Deliberate follow-ups

  • Race-scale TOCTOU between token check and write (pre-existing class, shrunk from human-scale; full closure needs file locking).
  • A legacy URL-equivalent duplicate entry is now never auto-deleted (correct trade: never delete what the preview never named); manual cleanup remains.
  • macOS 13: selectionDisabled unavailable — keyboard skip of unsupported rows is 14+; model-side guard remains the backstop.

Dumbris added 30 commits July 31, 2026 15:00
…ion undo, preview contract, legacy sheet routing)
…penCode absent case, sanitized existing entry, protocol coverage
…oken for all change kinds, protocol fixture split
…ary-by-construction, HMAC token incl. pending entry + adoption, refusal in preview, 409 discriminator, strict-socket, SC-006 home)
…te-capable scoping, absent-control convention
go test ./internal/connect/... ./internal/httpapi/... is green with no
pre-existing failures; the SC-003 undo round-trip is already covered by the
unchanged undo_test.go suite (byte-exact restore + created-file removal),
confirmed green here.
Related #91

502 XCTest cases, 0 failures on branch 091-connect-client-form before any
spec-091 Swift change.
The preview now reports WHAT a connect would overwrite without echoing any
client-config content: a new EntrySummary built by construction from a fixed
whitelist of parsed projections (entry name, transport type, endpoint with
query/userinfo/fragment stripped, command, header NAMES, env NAMES). The
existing-entry resolution mirrors the write, including OpenCode's
equivalent-entry adoption, so the summary names the key that actually
disappears.

Tests feed rotated header values, bearer/env secrets, ?apikey= and user:pass@
URLs, mcp-remote --header args and user-authored fields, and assert none of
those values appear anywhere in the serialized preview.
Related #91

ConnectPreviewModel decodes the spec-091 preview additions (sanitized
existing-entry summary, opaque precondition token, verbatim connect
refusal) and derives the change kind, safety-net statement and credential
notice the form renders before any Connect control exists. ClientStatus
gains icon/server_name/access_state/remediation, all optional so an older
core still decodes, with a generic symbol for an unknown client id.
DerivePreconditionToken is an HMAC-SHA256 over a canonical, length-prefixed
encoding of the raw pre-write state a preview described — config path, file
existence, the resolved (adoption-aware) target entry name and value — plus the
exact pending entry that would be written, so external drift AND proxy-side
drift (key rotation, require_mcp_auth toggle, listen-address change) both
invalidate a preview.

The key is per-core-instance, random and in-memory: the token is neither
forgeable nor usable as an offline confirmation oracle for a masked value, and
never outlives the process. Preview and the write share one pre-write-state
resolver so both hash the same lookup.
The absent-config guard that makes OpenCode non-connectable moves out of
Connect into a shared connectRefusal(), and the preview runs it, reporting the
reason verbatim in connect_refusal. A form can now hide its Connect control and
show the reason up front instead of letting the user discover it by clicking —
and can no longer promise "a new file will be created; Undo removes it" for a
client where that is false.

The test compares the preview's refusal to the real write's error text, so the
two guards cannot drift apart silently.
…ocket writes

Related #91

Adds clientDetail, connectPreview, connect (force + precondition token),
undoConnect and disconnect to APIClient, with a 409 surfaced as a typed
connectConflict carrying the core's action discriminator. transportKind
exposes whether the client talks over the private socket; the three
mutating calls refuse any other transport and are sent in strict-socket
mode, so a socket that disappears mid-session fails the write instead of
letting it ride TCP.
ConnectWithPrecondition re-resolves the pre-write state and the entry it would
write, recomputes the preview's token and refuses before any backup when
anything drifted — the file appearing or vanishing, the resolved (possibly
adopted) entry changing in a way the sanitized summary hides, or the proxy's own
config changing what would be written. force=true rides with the token for a
replace and never rescues a stale one; an absent token keeps today's behavior
for the Web UI and CLI.

The 409 now discriminates precondition_failed (re-preview) from already_exists
(pass force), at the top level and inside data, so a replace flow can branch
instead of looping. Swagger regenerated.
…form

Related #91

@mainactor reducer over a narrow data-source seam: stat-only list with a
2 s reachability poll, detail plus no-write preview on explicit selection,
and a Connect control that exists only while a preview is resolved for the
current client and entry name, was not refused by the core, and has a
readable config. Writes echo the preview's precondition token, an
overwrite sends force only together with that token, and a
precondition_failed conflict re-previews exactly once instead of
retrying. Mutating actions are disabled off-socket and while in flight.
Serves the real REST handler over a real Unix socket through the production
ListenerManager, multiplexListener and ConnContext, then POSTs
/api/v1/connect/{client} with no credential at all and requires the client
config to actually be written — joining listener, source tagging, admin auth
conversion and the write gate, which httptest can never do (it creates no
connection, so ConnContext never runs). A restricted agent token over TCP on
the same running server is the negative control.

The ConnContext hook moves out of the http.Server literal into a named
taggedConnContext so the test drives the production tagging verbatim rather
than a copy that could drift.
Related #91

Adds the "Connect Client…" tray item beside "Add Server…", routed through
one shared presentation route that the app delegate presents directly —
sheet on the main window, standalone window otherwise — with no delayed
notification chains. The view is thin over ConnectClientModel: stat-only
client list, detail plus preview pane (pending entry, config path,
sanitized existing-entry summary, safety-net statement, credential notice,
verbatim refusal), an Advanced entry-name override collapsed by default,
and an action bar whose Connect control only exists when the model says a
matching preview is rendered. Stable accessibility identifiers back the
UI tests.
Related #91

The list renders the cheap existence-only truth ("config present" / "no
config found" / unsupported + reason) and is overlaid with the
authoritative state only for clients the user explicitly selected, so
opening the form still reads no client config contents. A completed
connect or disconnect re-reads just the affected client instead of the
whole list.
Related #91

Undo appears only for a connect performed in this open form, carries that
connect's backup identity reduced to the bare filename the core requires
(a path is a 400), and disappears once used or once the form closes — the
core keeps no cross-session undo state. Disconnect now raises a
confirmation naming the config file and the entry; the request is sent
only by confirming it, so no control reaches the write without the
disclosure.
Related #91

The dashboard's Connect Clients button opened a sheet that wrote a
client's config directly — no preview, no backup disclosure. It now posts
the shared Connect Client presentation route, and the sheet is deleted
along with the preview-less connectToClient/disconnectFromClient API
methods it used, so no native path can write a client config without the
preview step (FR-012).
Adds docs/features/connect-clients.md covering the connect surface and the
two Spec 091 contract deltas:

- preview: existing_entry_summary (whitelist projection of the entry a
  connect would replace — names of headers/env, never values; endpoint with
  query, userinfo and fragment stripped) and connect_refusal (the write's own
  verbatim refusal, today OpenCode with no config);
- write: precondition_token (keyed HMAC over config path, file existence, the
  resolved entry's raw value and the pending entry) plus the discriminated
  409 (precondition_failed vs already_exists).

Mirrors both onto the REST reference. Also fixes quickstart.md — the manual
run needs the scratch --config file seeded first, since the core exits rather
than creating it — and records the manual protocol as NOT RUN.

Related #
findEquivalentJSONServerName returned the first match out of a
randomly-ordered Go map, and the preview, the precondition check and the
write each called it independently. Two entries pointing at the MCP
endpoint could therefore have the token hash one entry while the write
replaced or deleted another - a spurious precondition_failed, or a
silent force-delete of an entry the summary never named.

The matcher now prefers the requested name, then the lowest
URL-equivalent name in sorted order, and ConnectWithPrecondition
resolves the pre-write state once and threads that single resolution
through both the token check and the write.
The write refuses two things regardless of user intent: an absent
OpenCode config, and a .jsonc that actually uses comments (rewriting it
would strip them). Preview only ran the first, and a commented file
parses leniently, so the form rendered a resolved preview with an
enabled Connect control for a write that always returns 400 - exactly
the click-to-discover contradiction FR-003 exists to close.

Preview now runs both guards and reports the first refusal verbatim.
…oken

newExistingEntry projected the resolved config value to a map, so any
non-object value (a hand-edited string, number or array under the target
key) became a nil map and canonicalized to the constant "null". Two
completely different values therefore produced byte-identical tokens:
the drift was invisible, the precondition passed, and a force write
replaced state the user never saw - the exact class FR-005 promises to
catch.

The resolved entry now carries its raw value alongside the object
projection; the summary keeps using the projection, the token hashes the
raw value.
The connect, disconnect and undo handlers decoded the request body only
when r.ContentLength > 0. A chunked or otherwise streamed request has
ContentLength -1, so the whole body was dropped: force and
precondition_token vanished and the write ran in unguarded legacy mode,
which is the opposite of what a caller sending a token asked for.

Bodies are now decoded whenever one is present, with an empty body
treated as an absent one.
connectRefusal ran before the precondition check, so an OpenCode config
deleted between the preview and the confirm produced the flat "no
OpenCode config found" error (HTTP 400) instead of the discriminated
409 precondition_failed FR-005 mandates for a file disappearing after an
add/replace preview. The form read that as a hard failure rather than
re-previewing.

The refusal guard now runs after the drift check, so it still answers
the tokenless legacy path unchanged.
Dumbris added 8 commits July 31, 2026 19:18
The 409 was documented as contracts.ErrorResponse {success, error,
request_id}, while the handler writes {success, data, error, action}.
Spec 091 makes the top-level action THE discriminator between
already_exists and precondition_failed, so a client generated from the
OpenAPI document could not see the one field it must branch on.

Adds ConnectConflictResponse, writes it from the handler instead of an
ad-hoc map, and regenerates the spec.
…rite

The only busy signal was action == .inFlight, and invalidatePreview()
unconditionally reset action to .idle. Since neither the client list nor
the entry-name field was disabled, clicking another row or editing the
name while a POST was suspended cleared the busy state, re-enabled
Connect and allowed a second concurrent read-modify-write of the same
config file. On completion the outcome was published into whatever pane
was showing, so one client's success banner could render under another
client's name while its own row kept a stale label.

Busy is now its own flag, an outcome is only published for the client it
was performed on, and the affected client's row refreshes even when the
selection moved on. The post-action preview refresh no longer restores
its captured outcome over a newer action - an Undo started during that
refresh keeps its own state and cannot be sent twice.
The FR-009 guard bare-returned without reverting the List's own
selection, so clicking an unsupported client moved the highlight while
the detail pane, the rendered preview and an enabled Connect button all
still belonged to the previously selected client - pressing Connect then
wrote a config the user was not looking at.

The model now states which selection the list may hold and the view
snaps the highlight back to it. Re-selecting the current client is also
no longer a reselect, so the revert cannot trigger a redundant refetch.
connectControlExists hid the Connect control for any replace whose
token was empty, including against a core that does not send
precondition_token at all. A tray newer than its core therefore showed a
full preview, a backup promise and no button, with nothing said about
why - permanently.

The preview now records whether the core sent the field (a 091 core
always does, even empty), which separates "this core has no tokens" -
the documented legacy fallback, control available, no token sent - from
"this core owed me a token and gave none", which stays blocked but now
states its reason and stops promising a backup.
The standalone form window is .closable with isReleasedWhenClosed =
false, and only the form's own Close button ran the dismissal path. A
red-button close therefore just ordered the window out: the hosting
view, the model and the SwiftUI .task behind them stayed alive, so the
2 s core-reachability poll kept hitting the core from a window nobody
could see until the next presentation replaced it.

Window and model are now owned together by ConnectFormWindowLifecycle,
which both exits run through: the session-scoped undo ends and the view
hierarchy is released, which is what actually cancels the task.
…tion

The snap-back guard reverts the highlight after the fact, which is right
for a click but turns an arrow-key walk into a trap: the caret lands on
an unsupported row, is bounced back, and every supported row BELOW it
becomes unreachable by keyboard (a mouse can jump past it).

Rows now declare their selectability to the List via selectionDisabled,
so it skips them natively. That API is macOS 14+, and the package
deploys to macOS 13, so it is applied through an availability-gated
modifier; below 14 the model-side guard remains the backstop.
Presented as a sheet, the form's window never gets a close notification
of its own. Probed on macOS: AppKit lets a sheet-bearing parent close,
orders the sheet out with it, posts willClose for the HOST only, and
never runs beginSheet's completion handler - which was the only teardown
hook the sheet path had. Closing the main window with the Connect sheet
attached therefore leaked the model and its 2 s reachability poll.

The lifecycle now records the host at adoption and tears down on its
close too, with window.sheetParent as a second route for a form adopted
without one.
The lifecycle tests proved the hosting view and model are released, but
nothing ever started the poll, so its cancellation was only inferred.

This runs the poll for real against an unreachable core with a counting
sleeper: it iterates, and the counter freezes once the task carrying it
is cancelled. Verified by mutation - dropping loadList's Task.isCancelled
check turns it red with 6835 iterations after cancellation.

The SwiftUI half of the chain stays untested by necessity: probed here, a
.task never starts under swift test even with a key, activated window and
a hosting controller. The test comment records that.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0b7c70a
Status: ✅  Deploy successful!
Preview URL: https://9ecc183a.mcpproxy-docs.pages.dev
Branch Preview URL: https://091-connect-client-form.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Jul 31, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 89.18919% with 28 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/httpapi/connect.go 45.00% 7 Missing and 4 partials ⚠️
internal/connect/summary.go 87.14% 6 Missing and 3 partials ⚠️
internal/connect/token.go 93.54% 2 Missing and 2 partials ⚠️
internal/connect/preview.go 94.64% 3 Missing ⚠️
internal/server/listener_mux.go 75.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Dumbris added 2 commits July 31, 2026 21:11
The preimage hashed the RESOLVED entry name, which is the empty string
for every absent target, and never the requested one. Two previews for
different server_name values over the same config therefore produced an
identical token, so a token minted for "alpha" authorized a write that
created "beta" - a key the user never previewed. The same substitution
worked when two requested names adopted one equivalent entry.

The preimage is now a named PreconditionState carrying the client id and
the requested name alongside the observed state, both length-prefixed
like every other field. Verified by mutation: dropping the requested
name again makes the write create the substituted key.

research.md D1 described the old preimage; corrected.
Every os.Stat error collapsed to accessAbsent, so a config blocked by
permissions (its own, or its parent directory's) previewed as a create:
"this file does not exist; it will be created, and Undo removes it",
Connect available, token minted - and the denial was discovered only by
clicking, the very thing running the write's guards in the preview is
for.

Only IsNotExist is absence now. A permission error becomes the typed
AccessError (403 + remediation) and anything else is classified
conservatively as malformed, which offers no Connect control either. The
same misclassification is fixed in GetStatus and the stat-only
aggregate, and the OpenCode resolver now treats an unstattable candidate
as present rather than silently targeting the .json its .jsonc shadows.

Existence checks route through a stat seam alongside the existing read
seam, so this is testable without depending on permission bits; a real
chmod-000 test covers the OS path too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants