@@ -122,6 +122,126 @@ mcp/ # DeepSQL Phase 1 MCP server (Node stdio wrapper around back
122122agent/ # DeepSQL Agent (persona, skills, skins, Dockerfile for the Compose service)
123123```
124124
125+ ## Desktop Client (` desktop/ ` )
126+
127+ Cross-platform Electron client for a self-hosted DeepSQL VM. ** Separate npm
128+ project** — ` cd desktop && npm install ` , not part of the root ` package.json ` .
129+
130+ ``` bash
131+ cd desktop
132+ npm start # run
133+ npm run dev # run with DevTools
134+ npm test # drift guard for the DevTools kill switch
135+ npm run dist:mac # dmg + zip (arm64 + x64), also :win / :linux
136+ npm run smoke -- --url https://deepsql.example.com # headless connection check
137+ npm run selftest:tunnel # end-to-end SSH tunnel test (in-process SSH server)
138+ npm run selftest:settings # proves an edited setting reaches the live connection
139+ ```
140+
141+ ** A saved profile edit rebuilds the live connection; saving alone was never the
142+ bug.** The launcher persists the form before every Connect and Test, so
143+ ` profiles.json ` was always correct — but ` transport.connect() ` reused any live
144+ connection unconditionally, so changing a tunnel's remote port and pressing
145+ Connect did nothing, and Test reported a confident pass for settings the user had
146+ just replaced. ` profiles.transportFingerprint() ` now decides whether a live
147+ connection still * is* the connection being asked for; ` transport.reconcile() `
148+ rebuilds it on save (` ipc.saveAndReconcile ` ), and ` Workspace.updateProfile() `
149+ re-points the window, since a rebuilt tunnel binds a different local port and so
150+ changes the origin. The fingerprint deliberately excludes ` name ` (a rename must
151+ not drop a tunnel) and ` stickyLocalPort ` (chosen by us and rewritten every
152+ connect — including it would make a connection differ from itself). A failed
153+ rebuild does ** not** restore the old connection: it was built from settings that
154+ no longer exist, so it stays closed and the failure is reported. Entries also
155+ store a profile re-read * after* the connect path's trust-on-first-use writes, or
156+ the next connect would see a mismatch it caused itself.
157+
158+ ** DevTools are disabled in packaged builds, and ` IS_DEV ` is the wrong switch for
159+ it.** Every window passes ` webPreferences.devTools: DEVTOOLS_ENABLED ` , defined in
160+ ` config.js ` as ` !app.isPackaged ` and nothing else. Do not "simplify" it to
161+ ` IS_DEV ` : ` IS_DEV ` is also true when ` DEEPSQL_DESKTOP_DEV=1 ` , which any user can
162+ export against the shipped app — that is precisely the hole this closes, and it
163+ used to open DevTools automatically on both windows with no menu item involved.
164+ ` devTools: false ` is the load-bearing part (Chromium then refuses to attach at
165+ all, making ` openDevTools() ` a no-op); removing the menu item only hides the
166+ door, though it also drops the ` Alt+Cmd+I ` /` Ctrl+Shift+I ` binding, since a custom
167+ ` Menu.setApplicationMenu ` means Electron contributes no ` toggleDevTools ` role.
168+ Separately, ` index.js ` exits on ` --remote-debugging-port ` and friends: those open
169+ a DevTools * protocol* endpoint that ` devTools: false ` does not cover. Verified
170+ behaviourally on Electron 43 (` devTools:false ` → ` isDevToolsOpened() ` stays false
171+ after ` openDevTools() ` ; a ` devTools:true ` control opens, so the check is not
172+ vacuous). ` desktop/src/main/devtools.test.js ` fails the build if a new
173+ ` webPreferences ` block omits ` devTools ` — the regression is otherwise silent,
174+ since Chromium's default is * enabled* .
175+
176+ ** It is a thin client and deliberately does not bundle the React frontend.** It
177+ navigates a ` WebContentsView ` at the real DeepSQL origin, so the UI is always the
178+ version the VM is running — no bundle/backend skew, and no second copy of 40+
179+ tabs to maintain. ` docker/nginx/default.conf ` already serves the SPA, ` /api ` and
180+ ` /agent-api ` from one origin, so cookies and SSE behave exactly as in a browser.
181+ Do not "improve" this by bundling ` dist/ ` — that reintroduces ` SameSite ` and
182+ version-skew problems the current design does not have.
183+
184+ ** It needs exactly one piece of backend configuration, and CORS is it.** The
185+ "zero backend changes" claim that used to sit here was wrong, and cost a long
186+ debugging session. Over a tunnel the origin is ` http://127.0.0.1:<sticky port> ` ,
187+ not the VM's hostname, so a deployment whose ` CORS_ALLOWED_ORIGINS ` names only
188+ its public hostname rejects the desktop client. The failure is maximally
189+ misleading: Chromium omits ` Origin ` on same-origin GETs, so the health probe,
190+ the SPA and every read succeed, and the * first POST* — the login — comes back
191+ ` 403 ` with the plain-text body ` Invalid CORS request ` . That body has no
192+ ` message ` field, so ` client.js ` 's axios interceptor falls through to axios's own
193+ wording and the user sees ** "Request failed with status code 403"** , which names
194+ neither CORS nor the origin. Fix: keep loopback patterns in the allowlist —
195+ ` CORS_ALLOWED_ORIGINS=https://your-host,http://127.0.0.1:*,http://localhost:* ` .
196+ Port wildcards work only because ` SecurityConfig ` uses
197+ ` setAllowedOriginPatterns ` ; ` setAllowedOrigins ` would reject ` * ` alongside
198+ ` allowCredentials(true) ` . ` probe.js ` now sends an ` Origin ` header for exactly
199+ this reason, so the rejection is caught at connect time and named.
200+
201+ ** Two transports, one abstraction.** Both resolve to an * origin* , so nothing
202+ downstream of ` desktop/src/main/transport.js ` knows which is in use:
203+
204+ - ** Direct TLS** — the VM's HTTPS origin. Four certificate modes (` system ` ,
205+ ` pinned ` , ` custom-ca ` , ` insecure ` /TOFU), applied to ** both** the Node health
206+ probe and the Chromium session (` tls.applyToSession ` ). Applying it to only one
207+ gives a connection that tests green but renders a certificate error.
208+ - ** SSH tunnel** — ` ssh2 ` local forward, loopback-bound, no ` ssh ` binary needed.
209+ The local port is * sticky* across launches on purpose: the origin includes the
210+ port, and a fresh random port would silently reset the web app's
211+ ` localStorage ` . ` http://127.0.0.1:* ` is a Chromium secure context, so the
212+ backend's ` Secure ` cookies still work over the tunnel. ** Forward to the
213+ frontend container (3000), not a host reverse proxy on :80** — that proxy
214+ matches on ` server_name ` , a tunnel arrives with ` Host: 127.0.0.1:<port> ` ,
215+ and the request lands on the default vhost as a 404 that reads like a broken
216+ backend. The container's nginx uses ` server_name _ ` and answers any Host.
217+
218+ Three non-obvious things, all found the hard way:
219+
220+ 1 . ** ` Client.connect({ privateKey }) ` must get the raw key material, not the
221+ object ` sshUtils.parseKey ` returns.** Handed a parsed key, ssh2 silently
222+ never offers the publickey method and the server replies with a bare
223+ authentication failure — a symptom that points at the VM's ` authorized_keys `
224+ rather than at a type mismatch on our side. ` loadPrivateKey ` parses only to
225+ produce good error messages and returns the buffer.
226+ 2 . ** Authentication succeeding says nothing about forwarding being allowed.**
227+ A hardened sshd (` AllowTcpForwarding no ` ) accepts the login and refuses every
228+ ` direct-tcpip ` channel; the failure otherwise surfaces as "socket hang up" on
229+ the first browser request, pointing nowhere near sshd. ` verifyForwarding() `
230+ opens and closes one channel right after auth and classifies the refusal by
231+ SSH reason code — 1 (` ADMINISTRATIVELY_PROHIBITED ` , verified against real
232+ OpenSSH) names ` AllowTcpForwarding ` , 2 (` CONNECT_FAILED ` ) means nothing is
233+ listening on the remote port.
234+ 3 . ** Only a session that once reached ` ready ` may be reconnected.** Gating
235+ reconnects on ` everReady ` is what stops a connect that fails on
236+ authentication from retrying forever behind a caller that already surfaced
237+ the error.
238+
239+ Secrets (key passphrases, SSH passwords) are stored as ` safeStorage ` ciphertext;
240+ where no OS keychain exists nothing is written to disk and the launcher says so.
241+ Each profile gets its own session partition, so two DeepSQL servers never share
242+ cookies. ` .github/workflows/desktop-release.yml ` builds all three platforms on
243+ their native runners. See ` desktop/README.md ` for the full picture.
244+
125245## MCP Server
126246
127247- ` mcp/deepsql-phase1-server.js ` implements a Phase 1 stdio MCP server for internal rollout.
0 commit comments