Skip to content

Commit b99d1b2

Browse files
urstrulykkrK. Kaushik Reddyclaudevenkateshsakamuri-labgithub-advanced-security[bot]
authored
IDE version initial (#73)
IDE Version 1.0.0 --------- Co-authored-by: K. Kaushik Reddy <k.kaushikreddy@Ks-MacBook-Air.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Venkat SF <venkatesh.sakamuri@stayflexi.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 57568ed commit b99d1b2

45 files changed

Lines changed: 10150 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,17 @@ DEEPSQL_BACKEND_PORT=8080
221221
DEEPSQL_POSTGRES_PORT=5432
222222
DEEPSQL_VALKEY_PORT=6379
223223

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

227236
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
228237
# OPTIONAL — Email / SMTP
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
name: desktop-release
2+
3+
# Builds the DeepSQL desktop client for every platform. Each OS builds its own
4+
# targets on its own runner: cross-building Windows needs Wine and Linux targets
5+
# need a matching glibc, and both are far less reliable than just using the
6+
# native runner.
7+
#
8+
# Tag-triggered runs attach the installers to the GitHub release. Manual runs
9+
# upload them as workflow artifacts, which is the easy way to hand a build to a
10+
# colleague before there is a release to cut.
11+
12+
on:
13+
push:
14+
tags:
15+
- 'desktop-v*'
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
build:
23+
name: ${{ matrix.name }}
24+
runs-on: ${{ matrix.os }}
25+
strategy:
26+
fail-fast: false
27+
matrix:
28+
include:
29+
- { os: macos-latest, name: macOS, target: '--mac' }
30+
- { os: windows-latest, name: Windows, target: '--win' }
31+
- { os: ubuntu-latest, name: Linux, target: '--linux' }
32+
33+
steps:
34+
- uses: actions/checkout@v7
35+
36+
- uses: actions/setup-node@v7
37+
with:
38+
node-version: 22
39+
cache: npm
40+
cache-dependency-path: desktop/package-lock.json
41+
42+
- name: Install dependencies
43+
working-directory: desktop
44+
run: npm ci
45+
46+
- name: Self-test the SSH tunnel transport
47+
# Runs a real SSH server in-process, so it needs a display-free Electron.
48+
# xvfb is only required on Linux; the other runners have a window server.
49+
working-directory: desktop
50+
run: ${{ matrix.os == 'ubuntu-latest' && 'xvfb-run --auto-servernum npm run selftest:tunnel' || 'npm run selftest:tunnel' }}
51+
shell: bash
52+
53+
- name: Build installers
54+
working-directory: desktop
55+
env:
56+
# Signing is skipped when these are unset; electron-builder warns and
57+
# produces unsigned artifacts rather than failing the build.
58+
CSC_LINK: ${{ secrets.DESKTOP_CSC_LINK }}
59+
CSC_KEY_PASSWORD: ${{ secrets.DESKTOP_CSC_KEY_PASSWORD }}
60+
APPLE_ID: ${{ secrets.APPLE_ID }}
61+
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
62+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
63+
run: npx electron-builder ${{ matrix.target }} --publish never
64+
65+
- uses: actions/upload-artifact@v4
66+
with:
67+
name: deepsql-desktop-${{ matrix.name }}
68+
if-no-files-found: error
69+
path: |
70+
desktop/release/*.dmg
71+
desktop/release/*.zip
72+
desktop/release/*.exe
73+
desktop/release/*.AppImage
74+
desktop/release/*.deb
75+
desktop/release/*.rpm
76+
77+
release:
78+
name: attach to release
79+
needs: build
80+
if: startsWith(github.ref, 'refs/tags/desktop-v')
81+
runs-on: ubuntu-latest
82+
permissions:
83+
contents: write
84+
steps:
85+
- uses: actions/download-artifact@v4
86+
with:
87+
path: artifacts
88+
merge-multiple: true
89+
90+
- uses: softprops/action-gh-release@v2
91+
with:
92+
files: artifacts/*
93+
fail_on_unmatched_files: true

AGENTS.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ User Message → ChatController → SpringAIChatService
110110
- **UI state**: Zustand stores with selector hooks (`useActiveTab`, `useDashboardActions`)
111111
- **Independent chat threads**: Per-tab, per-connection, stored in localStorage
112112

113+
## Desktop Client (Electron)
114+
115+
`desktop/` is a standalone Electron app (its own `package.json`, not part of the
116+
root npm project). It is a **thin client**: it never bundles the React frontend,
117+
it navigates a `WebContentsView` at the real DeepSQL origin, so the UI is always
118+
the version the server runs. Two transports resolve to that origin — direct TLS,
119+
or an in-process SSH local forward (`ssh2`, no `ssh` binary needed).
120+
121+
| Path | Purpose |
122+
|------|---------|
123+
| `desktop/src/main/transport.js` | Transport manager: connect/disconnect/health per profile |
124+
| `desktop/src/main/tunnel.js` | SSH local forward, host-key TOFU-then-strict, auto-reconnect |
125+
| `desktop/src/main/tls.js` | Cert policy (system / pinned / custom CA / TOFU) for Node **and** Chromium |
126+
| `desktop/src/main/profiles.js` | Connection profiles; secrets only as `safeStorage` ciphertext |
127+
| `desktop/src/main/windows/workspace.js` | Frameless shell: native chrome + embedded DeepSQL view |
128+
| `desktop/src/renderer/shared/theme.css` | Mirrors `src/index.css` tokens — keep in step |
129+
130+
`docker/nginx/default.conf` already serves the SPA, `/api`, and `/agent-api` from
131+
one origin, so cookies and SSE behave like a normal browser. **CORS is the one
132+
backend setting the thin client still needs:** an SSH tunnel uses origin
133+
`http://127.0.0.1:<sticky-port>`, so `CORS_ALLOWED_ORIGINS` on the VM must keep
134+
the loopback port wildcards (`http://127.0.0.1:*,http://localhost:*`) alongside
135+
any public hostname. Overriding that env var *replaces* the built-in list — a
136+
public-origin-only value breaks Desktop tunnel login with a confusing 403.
137+
Setup and diagnosis: [`desktop/README.md`](desktop/README.md#cors-on-the-vm-the-403-nobody-can-read).
138+
113139
## Performance & Safety Guardrails
114140

115141
- Log size cap (500MB) via stream wrappers
@@ -287,10 +313,15 @@ only covers cloud-specific, non-obvious caveats.
287313
`deepsql agent --connection <uuid> "…"`. Interactive: `deepsql` / `deepsql agent`.
288314
The CLI is a thin client over `POST /api/agent/chat` (not a local agent runtime);
289315
backend + agent API (:8787) + provisioner must already be up.
290-
- **Spring CORS must allow both loopback hosts.** Set
291-
`CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000` in `.env`. Opening
292-
the UI as `http://127.0.0.1:3000` while only `localhost` is allowlisted yields **403**
293-
on `POST /api/agent/session` (and other cookie-auth APIs).
316+
- **Spring CORS must allow loopback (and Desktop tunnel ports).** Prefer
317+
`CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:*,http://localhost:*`
318+
in `.env` (match `.env.example`). Fixed `:3000` alone is enough for Vite on that
319+
port; the `*` port wildcards are required for DeepSQL Desktop’s SSH tunnel, which
320+
binds a sticky random local port. Opening the UI as `http://127.0.0.1:…` while
321+
only `localhost` (or only a public hostname) is allowlisted yields **403** on
322+
`POST /api/agent/session` (and other cookie-auth APIs). Electron GUI itself is
323+
out of scope for headless Cloud Agents — use Vite + these CORS patterns here;
324+
see [`desktop/README.md`](desktop/README.md) when developing the client.
294325
- **Before running backend tests that boot the Spring context** (e.g. `ApiSmokeTest`), stop
295326
the running backend first — both use `ddl-auto=update` on the same `dba_agent` DB and can
296327
deadlock on an `ALTER TABLE`. Test env vars are documented in `CLAUDE.md` (Testing).

CLAUDE.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,126 @@ mcp/ # DeepSQL Phase 1 MCP server (Node stdio wrapper around back
122122
agent/ # 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.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,22 @@ your schema — all from one shared brain.
241241
- **Postgres and MySQL, in your infra.** One dialect registry, read-only execution, and SSH
242242
tunnelling to reach databases behind a bastion.
243243

244+
### DeepSQL Desktop (optional)
245+
246+
A thin Electron client for a self-hosted VM — direct TLS or an in-process SSH tunnel —
247+
without bundling a second copy of the web UI. Separate npm project:
248+
249+
```bash
250+
cd desktop
251+
npm install
252+
npm start
253+
```
254+
255+
Requires **Node 22+**. Forward the tunnel to the **frontend container (port 3000)**, not a
256+
host reverse proxy on `:80`. Keep loopback CORS wildcards on the VM
257+
(`http://127.0.0.1:*,http://localhost:*`) or tunnel login fails with a confusing 403 — see
258+
[`desktop/README.md`](desktop/README.md).
259+
244260
---
245261

246262
## Operating the stack

backend/src/main/java/com/dbaagent/config/SecurityConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ public class SecurityConfig {
5252
* only {@code application*.properties} — could not see it, and it ships to every reader
5353
* of the public repository. {@code CorsAllowlistSafetyTest} now scans this file too.
5454
*/
55-
@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}")
55+
@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:*}")
5656
private String corsAllowedOrigins;
5757

5858
@Bean

backend/src/main/resources/application-prod.properties

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@ app.base-url=${APP_BASE_URL:http://localhost:3000}
5757

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

6366
# File upload limits (slow query logs can be large)
6467
spring.servlet.multipart.max-file-size=2048MB

backend/src/main/resources/application.properties

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,22 @@ db-scheduler.shutdown-max-wait=45m
9393
db-scheduler.immediate-execution-enabled=true
9494

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

101113
# File upload limits (slow query logs can be large)
102114
spring.servlet.multipart.max-file-size=2048MB

desktop/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
release/
3+
build/icon.iconset/
4+
*.log

0 commit comments

Comments
 (0)