Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,17 @@ DEEPSQL_BACKEND_PORT=8080
DEEPSQL_POSTGRES_PORT=5432
DEEPSQL_VALKEY_PORT=6379

# Browser origins allowed to call the backend
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Browser origins allowed to call the backend.
#
# This REPLACES the built-in list — it does not add to it. When you put your own
# hostname here, keep the loopback patterns too. The desktop client reaches a VM
# over an SSH tunnel and therefore serves the app from http://127.0.0.1:<port>,
# with a port picked at runtime; if that origin is not allowed the app loads
# normally and then every login fails with a bare "403 Invalid CORS request".
# The `*` is a port wildcard (SecurityConfig uses setAllowedOriginPatterns).
#
# CORS_ALLOWED_ORIGINS=https://deepsql.example.com,http://127.0.0.1:*,http://localhost:*
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:*,http://localhost:*

# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# OPTIONAL — Email / SMTP
Expand Down
93 changes: 93 additions & 0 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: desktop-release

# Builds the DeepSQL desktop client for every platform. Each OS builds its own
# targets on its own runner: cross-building Windows needs Wine and Linux targets
# need a matching glibc, and both are far less reliable than just using the
# native runner.
#
# Tag-triggered runs attach the installers to the GitHub release. Manual runs
# upload them as workflow artifacts, which is the easy way to hand a build to a
# colleague before there is a release to cut.

on:
push:
tags:
- 'desktop-v*'
workflow_dispatch:

permissions:
contents: read

jobs:
build:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- { os: macos-latest, name: macOS, target: '--mac' }
- { os: windows-latest, name: Windows, target: '--win' }
- { os: ubuntu-latest, name: Linux, target: '--linux' }

steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
cache-dependency-path: desktop/package-lock.json

- name: Install dependencies
working-directory: desktop
run: npm ci

- name: Self-test the SSH tunnel transport
# Runs a real SSH server in-process, so it needs a display-free Electron.
# xvfb is only required on Linux; the other runners have a window server.
working-directory: desktop
run: ${{ matrix.os == 'ubuntu-latest' && 'xvfb-run --auto-servernum npm run selftest:tunnel' || 'npm run selftest:tunnel' }}
shell: bash

- name: Build installers
working-directory: desktop
env:
# Signing is skipped when these are unset; electron-builder warns and
# produces unsigned artifacts rather than failing the build.
CSC_LINK: ${{ secrets.DESKTOP_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npx electron-builder ${{ matrix.target }} --publish never

- uses: actions/upload-artifact@v4
with:
name: deepsql-desktop-${{ matrix.name }}
if-no-files-found: error
path: |
desktop/release/*.dmg
desktop/release/*.zip
desktop/release/*.exe
desktop/release/*.AppImage
desktop/release/*.deb
desktop/release/*.rpm

release:
name: attach to release
needs: build
if: startsWith(github.ref, 'refs/tags/desktop-v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true

- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
fail_on_unmatched_files: true
39 changes: 35 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ User Message → ChatController → SpringAIChatService
- **UI state**: Zustand stores with selector hooks (`useActiveTab`, `useDashboardActions`)
- **Independent chat threads**: Per-tab, per-connection, stored in localStorage

## Desktop Client (Electron)

`desktop/` is a standalone Electron app (its own `package.json`, not part of the
root npm project). It is a **thin client**: it never bundles the React frontend,
it navigates a `WebContentsView` at the real DeepSQL origin, so the UI is always
the version the server runs. Two transports resolve to that origin — direct TLS,
or an in-process SSH local forward (`ssh2`, no `ssh` binary needed).

| Path | Purpose |
|------|---------|
| `desktop/src/main/transport.js` | Transport manager: connect/disconnect/health per profile |
| `desktop/src/main/tunnel.js` | SSH local forward, host-key TOFU-then-strict, auto-reconnect |
| `desktop/src/main/tls.js` | Cert policy (system / pinned / custom CA / TOFU) for Node **and** Chromium |
| `desktop/src/main/profiles.js` | Connection profiles; secrets only as `safeStorage` ciphertext |
| `desktop/src/main/windows/workspace.js` | Frameless shell: native chrome + embedded DeepSQL view |
| `desktop/src/renderer/shared/theme.css` | Mirrors `src/index.css` tokens — keep in step |

`docker/nginx/default.conf` already serves the SPA, `/api`, and `/agent-api` from
one origin, so cookies and SSE behave like a normal browser. **CORS is the one
backend setting the thin client still needs:** an SSH tunnel uses origin
`http://127.0.0.1:<sticky-port>`, so `CORS_ALLOWED_ORIGINS` on the VM must keep
the loopback port wildcards (`http://127.0.0.1:*,http://localhost:*`) alongside
any public hostname. Overriding that env var *replaces* the built-in list — a
public-origin-only value breaks Desktop tunnel login with a confusing 403.
Setup and diagnosis: [`desktop/README.md`](desktop/README.md#cors-on-the-vm-the-403-nobody-can-read).

## Performance & Safety Guardrails

- Log size cap (500MB) via stream wrappers
Expand Down Expand Up @@ -287,10 +313,15 @@ only covers cloud-specific, non-obvious caveats.
`deepsql agent --connection <uuid> "…"`. Interactive: `deepsql` / `deepsql agent`.
The CLI is a thin client over `POST /api/agent/chat` (not a local agent runtime);
backend + agent API (:8787) + provisioner must already be up.
- **Spring CORS must allow both loopback hosts.** Set
`CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` in `.env`. Opening
the UI as `http://127.0.0.1:3000` while only `localhost` is allowlisted yields **403**
on `POST /api/agent/session` (and other cookie-auth APIs).
- **Spring CORS must allow loopback (and Desktop tunnel ports).** Prefer
`CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:*,http://localhost:*`
in `.env` (match `.env.example`). Fixed `:3000` alone is enough for Vite on that
port; the `*` port wildcards are required for DeepSQL Desktop’s SSH tunnel, which
binds a sticky random local port. Opening the UI as `http://127.0.0.1:…` while
only `localhost` (or only a public hostname) is allowlisted yields **403** on
`POST /api/agent/session` (and other cookie-auth APIs). Electron GUI itself is
out of scope for headless Cloud Agents — use Vite + these CORS patterns here;
see [`desktop/README.md`](desktop/README.md) when developing the client.
- **Before running backend tests that boot the Spring context** (e.g. `ApiSmokeTest`), stop
the running backend first — both use `ddl-auto=update` on the same `dba_agent` DB and can
deadlock on an `ALTER TABLE`. Test env vars are documented in `CLAUDE.md` (Testing).
Expand Down
120 changes: 120 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,126 @@ mcp/ # DeepSQL Phase 1 MCP server (Node stdio wrapper around back
agent/ # DeepSQL Agent (persona, skills, skins, Dockerfile for the Compose service)
```

## Desktop Client (`desktop/`)

Cross-platform Electron client for a self-hosted DeepSQL VM. **Separate npm
project** — `cd desktop && npm install`, not part of the root `package.json`.

```bash
cd desktop
npm start # run
npm run dev # run with DevTools
npm test # drift guard for the DevTools kill switch
npm run dist:mac # dmg + zip (arm64 + x64), also :win / :linux
npm run smoke -- --url https://deepsql.example.com # headless connection check
npm run selftest:tunnel # end-to-end SSH tunnel test (in-process SSH server)
npm run selftest:settings # proves an edited setting reaches the live connection
```

**A saved profile edit rebuilds the live connection; saving alone was never the
bug.** The launcher persists the form before every Connect and Test, so
`profiles.json` was always correct — but `transport.connect()` reused any live
connection unconditionally, so changing a tunnel's remote port and pressing
Connect did nothing, and Test reported a confident pass for settings the user had
just replaced. `profiles.transportFingerprint()` now decides whether a live
connection still *is* the connection being asked for; `transport.reconcile()`
rebuilds it on save (`ipc.saveAndReconcile`), and `Workspace.updateProfile()`
re-points the window, since a rebuilt tunnel binds a different local port and so
changes the origin. The fingerprint deliberately excludes `name` (a rename must
not drop a tunnel) and `stickyLocalPort` (chosen by us and rewritten every
connect — including it would make a connection differ from itself). A failed
rebuild does **not** restore the old connection: it was built from settings that
no longer exist, so it stays closed and the failure is reported. Entries also
store a profile re-read *after* the connect path's trust-on-first-use writes, or
the next connect would see a mismatch it caused itself.

**DevTools are disabled in packaged builds, and `IS_DEV` is the wrong switch for
it.** Every window passes `webPreferences.devTools: DEVTOOLS_ENABLED`, defined in
`config.js` as `!app.isPackaged` and nothing else. Do not "simplify" it to
`IS_DEV`: `IS_DEV` is also true when `DEEPSQL_DESKTOP_DEV=1`, which any user can
export against the shipped app — that is precisely the hole this closes, and it
used to open DevTools automatically on both windows with no menu item involved.
`devTools: false` is the load-bearing part (Chromium then refuses to attach at
all, making `openDevTools()` a no-op); removing the menu item only hides the
door, though it also drops the `Alt+Cmd+I`/`Ctrl+Shift+I` binding, since a custom
`Menu.setApplicationMenu` means Electron contributes no `toggleDevTools` role.
Separately, `index.js` exits on `--remote-debugging-port` and friends: those open
a DevTools *protocol* endpoint that `devTools: false` does not cover. Verified
behaviourally on Electron 43 (`devTools:false` → `isDevToolsOpened()` stays false
after `openDevTools()`; a `devTools:true` control opens, so the check is not
vacuous). `desktop/src/main/devtools.test.js` fails the build if a new
`webPreferences` block omits `devTools` — the regression is otherwise silent,
since Chromium's default is *enabled*.

**It is a thin client and deliberately does not bundle the React frontend.** It
navigates a `WebContentsView` at the real DeepSQL origin, so the UI is always the
version the VM is running — no bundle/backend skew, and no second copy of 40+
tabs to maintain. `docker/nginx/default.conf` already serves the SPA, `/api` and
`/agent-api` from one origin, so cookies and SSE behave exactly as in a browser.
Do not "improve" this by bundling `dist/` — that reintroduces `SameSite` and
version-skew problems the current design does not have.

**It needs exactly one piece of backend configuration, and CORS is it.** The
"zero backend changes" claim that used to sit here was wrong, and cost a long
debugging session. Over a tunnel the origin is `http://127.0.0.1:<sticky port>`,
not the VM's hostname, so a deployment whose `CORS_ALLOWED_ORIGINS` names only
its public hostname rejects the desktop client. The failure is maximally
misleading: Chromium omits `Origin` on same-origin GETs, so the health probe,
the SPA and every read succeed, and the *first POST* — the login — comes back
`403` with the plain-text body `Invalid CORS request`. That body has no
`message` field, so `client.js`'s axios interceptor falls through to axios's own
wording and the user sees **"Request failed with status code 403"**, which names
neither CORS nor the origin. Fix: keep loopback patterns in the allowlist —
`CORS_ALLOWED_ORIGINS=https://your-host,http://127.0.0.1:*,http://localhost:*`.
Port wildcards work only because `SecurityConfig` uses
`setAllowedOriginPatterns`; `setAllowedOrigins` would reject `*` alongside
`allowCredentials(true)`. `probe.js` now sends an `Origin` header for exactly
this reason, so the rejection is caught at connect time and named.

**Two transports, one abstraction.** Both resolve to an *origin*, so nothing
downstream of `desktop/src/main/transport.js` knows which is in use:

- **Direct TLS** — the VM's HTTPS origin. Four certificate modes (`system`,
`pinned`, `custom-ca`, `insecure`/TOFU), applied to **both** the Node health
probe and the Chromium session (`tls.applyToSession`). Applying it to only one
gives a connection that tests green but renders a certificate error.
- **SSH tunnel** — `ssh2` local forward, loopback-bound, no `ssh` binary needed.
The local port is *sticky* across launches on purpose: the origin includes the
port, and a fresh random port would silently reset the web app's
`localStorage`. `http://127.0.0.1:*` is a Chromium secure context, so the
backend's `Secure` cookies still work over the tunnel. **Forward to the
frontend container (3000), not a host reverse proxy on :80** — that proxy
matches on `server_name`, a tunnel arrives with `Host: 127.0.0.1:<port>`,
and the request lands on the default vhost as a 404 that reads like a broken
backend. The container's nginx uses `server_name _` and answers any Host.

Three non-obvious things, all found the hard way:

1. **`Client.connect({ privateKey })` must get the raw key material, not the
object `sshUtils.parseKey` returns.** Handed a parsed key, ssh2 silently
never offers the publickey method and the server replies with a bare
authentication failure — a symptom that points at the VM's `authorized_keys`
rather than at a type mismatch on our side. `loadPrivateKey` parses only to
produce good error messages and returns the buffer.
2. **Authentication succeeding says nothing about forwarding being allowed.**
A hardened sshd (`AllowTcpForwarding no`) accepts the login and refuses every
`direct-tcpip` channel; the failure otherwise surfaces as "socket hang up" on
the first browser request, pointing nowhere near sshd. `verifyForwarding()`
opens and closes one channel right after auth and classifies the refusal by
SSH reason code — 1 (`ADMINISTRATIVELY_PROHIBITED`, verified against real
OpenSSH) names `AllowTcpForwarding`, 2 (`CONNECT_FAILED`) means nothing is
listening on the remote port.
3. **Only a session that once reached `ready` may be reconnected.** Gating
reconnects on `everReady` is what stops a connect that fails on
authentication from retrying forever behind a caller that already surfaced
the error.

Secrets (key passphrases, SSH passwords) are stored as `safeStorage` ciphertext;
where no OS keychain exists nothing is written to disk and the launcher says so.
Each profile gets its own session partition, so two DeepSQL servers never share
cookies. `.github/workflows/desktop-release.yml` builds all three platforms on
their native runners. See `desktop/README.md` for the full picture.

## MCP Server

- `mcp/deepsql-phase1-server.js` implements a Phase 1 stdio MCP server for internal rollout.
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,22 @@ your schema — all from one shared brain.
- **Postgres and MySQL, in your infra.** One dialect registry, read-only execution, and SSH
tunnelling to reach databases behind a bastion.

### DeepSQL Desktop (optional)

A thin Electron client for a self-hosted VM — direct TLS or an in-process SSH tunnel —
without bundling a second copy of the web UI. Separate npm project:

```bash
cd desktop
npm install
npm start
```

Requires **Node 22+**. Forward the tunnel to the **frontend container (port 3000)**, not a
host reverse proxy on `:80`. Keep loopback CORS wildcards on the VM
(`http://127.0.0.1:*,http://localhost:*`) or tunnel login fails with a confusing 403 — see
[`desktop/README.md`](desktop/README.md).

---

## Operating the stack
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public class SecurityConfig {
* only {@code application*.properties} — could not see it, and it ships to every reader
* of the public repository. {@code CorsAllowlistSafetyTest} now scans this file too.
*/
@Value("${cors.allowed.origins:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002}")
@Value("${cors.allowed.origins:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:*,http://localhost:*}")
private String corsAllowedOrigins;

@Bean
Expand Down
7 changes: 5 additions & 2 deletions backend/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,11 @@ app.base-url=${APP_BASE_URL:http://localhost:3000}

# CORS Configuration
# Override CORS_ALLOWED_ORIGINS with the origin(s) your frontend is
# actually served from. The default below covers local dev only.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001}
# actually served from, and keep the loopback patterns: the desktop
# client's SSH tunnel serves the app from http://127.0.0.1:<sticky port>,
# so dropping them rejects every tunnel login with 403 "Invalid CORS
# request". See the longer note in application.properties.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://127.0.0.1:*,http://localhost:*}

# File upload limits (slow query logs can be large)
spring.servlet.multipart.max-file-size=2048MB
Expand Down
18 changes: 15 additions & 3 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,22 @@ db-scheduler.shutdown-max-wait=45m
db-scheduler.immediate-execution-enabled=true

# CORS Configuration
# Localhost only by default so `mvn spring-boot:run` + `npm run dev` works out of the box.
# Loopback only by default so `mvn spring-boot:run` + `npm run dev` works out of the box.
# Any deployment serving a browser from another host must set CORS_ALLOWED_ORIGINS.
# Note: exact origins (no patterns) for reliability.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002}
#
# The loopback entries carry a port wildcard because the desktop client's SSH tunnel
# serves the app from http://127.0.0.1:<sticky port>, and that port is chosen at runtime.
# Enumerating ports here is what made every tunnel connection fail with a bare
# "403 Invalid CORS request": Spring treats any request carrying Origin as cross-origin
# (the same-origin short-circuit went away in 5.3), so an unlisted loopback port is
# rejected. SecurityConfig uses setAllowedOriginPatterns, so `*` is legal in the port
# position and stays compatible with allowCredentials(true) — plain setAllowedOrigins
# would not be.
#
# IMPORTANT: overriding CORS_ALLOWED_ORIGINS replaces this list wholesale. A deployment
# that sets it to its public hostname alone drops the loopback entries and breaks the
# desktop client. Keep the loopback patterns alongside your hostname.
cors.allowed.origins=${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:3001,http://localhost:3002,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:*,http://localhost:*}

# File upload limits (slow query logs can be large)
spring.servlet.multipart.max-file-size=2048MB
Expand Down
4 changes: 4 additions & 0 deletions desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
release/
build/icon.iconset/
*.log
Loading
Loading