@@ -122,6 +122,72 @@ 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 npm run dev # run with DevTools
133+ npm run dist:mac # dmg + zip (arm64 + x64), also :win / :linux
134+ npm run smoke -- --url https://deepsql.example.com # headless connection check
135+ npm run selftest:tunnel # end-to-end SSH tunnel test (in-process SSH server)
136+ ```
137+
138+ ** It is a thin client and deliberately does not bundle the React frontend.** It
139+ navigates a ` WebContentsView ` at the real DeepSQL origin, so the UI is always the
140+ version the VM is running — no bundle/backend skew, and no second copy of 40+
141+ tabs to maintain. This works with ** zero backend changes** because
142+ ` docker/nginx/default.conf ` already serves the SPA, ` /api ` and ` /agent-api ` from
143+ one origin: cookies, CORS and SSE behave exactly as in a browser. Do not
144+ "improve" this by bundling ` dist/ ` — that reintroduces CORS, ` SameSite ` , and
145+ version-skew problems the current design does not have.
146+
147+ ** Two transports, one abstraction.** Both resolve to an * origin* , so nothing
148+ downstream of ` desktop/src/main/transport.js ` knows which is in use:
149+
150+ - ** Direct TLS** — the VM's HTTPS origin. Four certificate modes (` system ` ,
151+ ` pinned ` , ` custom-ca ` , ` insecure ` /TOFU), applied to ** both** the Node health
152+ probe and the Chromium session (` tls.applyToSession ` ). Applying it to only one
153+ gives a connection that tests green but renders a certificate error.
154+ - ** SSH tunnel** — ` ssh2 ` local forward, loopback-bound, no ` ssh ` binary needed.
155+ The local port is * sticky* across launches on purpose: the origin includes the
156+ port, and a fresh random port would silently reset the web app's
157+ ` localStorage ` . ` http://127.0.0.1:* ` is a Chromium secure context, so the
158+ backend's ` Secure ` cookies still work over the tunnel. ** Forward to the
159+ frontend container (3000), not a host reverse proxy on :80** — that proxy
160+ matches on ` server_name ` , a tunnel arrives with ` Host: 127.0.0.1:<port> ` ,
161+ and the request lands on the default vhost as a 404 that reads like a broken
162+ backend. The container's nginx uses ` server_name _ ` and answers any Host.
163+
164+ Three non-obvious things, all found the hard way:
165+
166+ 1 . ** ` Client.connect({ privateKey }) ` must get the raw key material, not the
167+ object ` sshUtils.parseKey ` returns.** Handed a parsed key, ssh2 silently
168+ never offers the publickey method and the server replies with a bare
169+ authentication failure — a symptom that points at the VM's ` authorized_keys `
170+ rather than at a type mismatch on our side. ` loadPrivateKey ` parses only to
171+ produce good error messages and returns the buffer.
172+ 2 . ** Authentication succeeding says nothing about forwarding being allowed.**
173+ A hardened sshd (` AllowTcpForwarding no ` ) accepts the login and refuses every
174+ ` direct-tcpip ` channel; the failure otherwise surfaces as "socket hang up" on
175+ the first browser request, pointing nowhere near sshd. ` verifyForwarding() `
176+ opens and closes one channel right after auth and classifies the refusal by
177+ SSH reason code — 1 (` ADMINISTRATIVELY_PROHIBITED ` , verified against real
178+ OpenSSH) names ` AllowTcpForwarding ` , 2 (` CONNECT_FAILED ` ) means nothing is
179+ listening on the remote port.
180+ 3 . ** Only a session that once reached ` ready ` may be reconnected.** Gating
181+ reconnects on ` everReady ` is what stops a connect that fails on
182+ authentication from retrying forever behind a caller that already surfaced
183+ the error.
184+
185+ Secrets (key passphrases, SSH passwords) are stored as ` safeStorage ` ciphertext;
186+ where no OS keychain exists nothing is written to disk and the launcher says so.
187+ Each profile gets its own session partition, so two DeepSQL servers never share
188+ cookies. ` .github/workflows/desktop-release.yml ` builds all three platforms on
189+ their native runners. See ` desktop/README.md ` for the full picture.
190+
125191## MCP Server
126192
127193- ` mcp/deepsql-phase1-server.js ` implements a Phase 1 stdio MCP server for internal rollout.
0 commit comments