Repository navigation
feat(web): add self-hosted web frontend for the desktop backend - #3250
PeterPange wants to merge 1 commit into
Conversation
| const root = resolve(webRoot); | ||
| return function handleStatic(req: IncomingMessage, res: ServerResponse): boolean { | ||
| if (req.method !== 'GET' && req.method !== 'HEAD') return false; | ||
| const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]); |
There was a problem hiding this comment.
An unauthenticated request with a malformed path such as /% causes decodeURIComponent to throw. The HTTP request callback does not catch that exception, so one malformed request can terminate the self-hosted server instead of receiving a 400 response.
How this was verified: The attacker-controlled req.url is decoded synchronously without a surrounding catch in the server's request path.
| const urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]); | |
| let urlPath: string; | |
| try { | |
| urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]); | |
| } catch { | |
| res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' }); | |
| res.end('Bad request'); | |
| return true; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/server/static.ts
Line: 33
Comment:
**Malformed URLs Crash Server**
An unauthenticated request with a malformed path such as `/%` causes `decodeURIComponent` to throw. The HTTP request callback does not catch that exception, so one malformed request can terminate the self-hosted server instead of receiving a 400 response.
**How this was verified:** The attacker-controlled `req.url` is decoded synchronously without a surrounding catch in the server's request path.
```suggestion
let urlPath: string;
try {
urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
} catch {
res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
res.end('Bad request');
return true;
}
```
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| server.listen(PORT, HOST, () => { | ||
| const displayHost = HOST === '0.0.0.0' ? '<machine-ip>' : HOST; | ||
| console.log(''); | ||
| console.log(' Emdash Web is running'); | ||
| console.log(` URL : http://${displayHost}:${PORT}/`); | ||
| console.log(` Token : ${TOKEN}`); | ||
| console.log(` Open : http://${displayHost}:${PORT}/?token=${TOKEN}`); |
There was a problem hiding this comment.
When the server is bound to 0.0.0.0 for the documented LAN use case, it still uses plaintext HTTP and WebSocket transport. The startup URL and every WebSocket connection place the full-access bearer token in the query string, so anyone able to observe network traffic can recover it and gain project, Git, and terminal access. Non-loopback use needs TLS, a required TLS-terminating proxy, or an explicit secure configuration gate.
How this was verified: The non-loopback startup path prints an http:// token URL and the gateway authenticates that same token from the plaintext WebSocket request query.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/server/index.ts
Line: 122-128
Comment:
**LAN Token Uses Plaintext**
When the server is bound to `0.0.0.0` for the documented LAN use case, it still uses plaintext HTTP and WebSocket transport. The startup URL and every WebSocket connection place the full-access bearer token in the query string, so anyone able to observe network traffic can recover it and gain project, Git, and terminal access. Non-loopback use needs TLS, a required TLS-terminating proxy, or an explicit secure configuration gate.
**How this was verified:** The non-loopback startup path prints an `http://` token URL and the gateway authenticates that same token from the plaintext WebSocket request query.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| isEncryptionAvailable(): boolean { | ||
| return false; | ||
| }, |
There was a problem hiding this comment.
Secure Credentials Always Fail
This shim always reports secure storage as unavailable, but the reused backend supplies encryptedAppSecretsStore to SSH credentials, provider accounts, and account sessions. That store checks this value before every secret read or write and throws when it is false, so password or passphrase SSH connections and provider or account credential persistence cannot work in the web frontend. The web backend needs a secure Node-compatible secret store or another explicitly supported implementation.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/server/shim/electron.ts
Line: 248-250
Comment:
**Secure Credentials Always Fail**
This shim always reports secure storage as unavailable, but the reused backend supplies `encryptedAppSecretsStore` to SSH credentials, provider accounts, and account sessions. That store checks this value before every secret read or write and throws when it is false, so password or passphrase SSH connections and provider or account credential persistence cannot work in the web frontend. The web backend needs a secure Node-compatible secret store or another explicitly supported implementation.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.There was a problem hiding this comment.
A rejected token is retried forever using the same value stored in localStorage. This happens by default after a server restart without EMDASH_WEB_TOKEN, because the server generates a new token while the browser keeps the old one. The reconnect loop never becomes terminal or clears the token, and the token prompt only appears when no stored value exists, leaving the application stuck on its boot screen until the user manually clears storage or opens the newly printed token URL.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-web/web/seed-web-wire.ts
Line: 109-112
Comment:
**Stale Tokens Retry Forever**
A rejected token is retried forever using the same value stored in localStorage. This happens by default after a server restart without `EMDASH_WEB_TOKEN`, because the server generates a new token while the browser keeps the old one. The reconnect loop never becomes terminal or clears the token, and the token prompt only appears when no stored value exists, leaving the application stuck on its boot screen until the user manually clears storage or opens the newly printed token URL.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Adds apps/emdash-web, a Node server that boots the desktop backend stack unchanged (database, infrastructure, runtimes, services, and the full wire controller bundle) and exposes it over WebSocket, plus a browser build of the renderer. - server: electron module shim, boot orchestration reusing the desktop boot phases, WebSocket wire gateway with token auth (streamTransport framing, domain routing), static file serving - web: renderer bootstrap with a WebSocket wire seed and an electronAPI shim; local-directory selector override replaces the native OS dialog with manual path entry - build: esbuild bundling for the server and the runtime worker artifacts, with plugins for ?asset imports, core # subpath imports, and import.meta.glob inlining Security hardening: - malformed request paths return 400 instead of crashing the server - non-loopback binds refuse to start without an explicit EMDASH_WEB_ALLOW_INSECURE_REMOTE=1 acknowledgment (token travels in plaintext; a TLS-terminating proxy is the supported path) - safeStorage shim implements AES-256-GCM with a 0600 key file so the encrypted app secrets store works under plain Node - rejected tokens close the WebSocket with code 4401; the client clears the stale value and re-prompts instead of retrying forever Verified end-to-end: wire calls, live model snapshots, project creation, and the renderer build all run against a plain Node process with no Electron present.
8c542d0 to
380547d
Compare

Description
This adds
apps/emdash-web: a self-hosted web frontend that runs the desktopbackend stack under plain Node and serves the renderer in a browser — useful
for headless/remote machines where running the Electron app is impractical
(e.g. a lab server accessed over SSH).
The desktop backend is reused unchanged:
bootDatabase→bootInfrastructure→bootRuntimes→bootServices→bootControllersphases, with anelectronmodule shim(paths, dialogs, tray, power monitor) aliased in at build time
streamTransportframing the workspace-server uses, with domain routing mirroring the desktop
gateway, plus a token check on upgrade (the token grants full project/git/
terminal access, so it is printed at startup and never logged elsewhere)
swapped to a reconnecting WebSocket transport and a no-op
electronAPIshim;one UI override replaces the native directory dialog with manual path entry
(validated live via
inspectProjectPath)desktopWorkerPathresolver expects, so worker forking works unchangedKnown limitations (desktop-only surfaces are stubbed): OS dialogs, clipboard
writes, tray/menu, auto-update, and the Electron-webview in-app browser.
OAuth flows that target desktop callback ports are also out of scope here.
This is a large PR (a new app), which I realize is outside the "small and
focused" guidance. I'm happy to restructure it into smaller steps if that
helps review — for example landing the electron shim + boot reuse first,
then the gateway, then the renderer build. Feedback on the shim approach
itself is also welcome; if you'd rather see the boot phases decoupled from
Electron properly instead of shimmed, I can take that direction.
Related issues
None yet — this is a new capability. Happy to open a tracking issue first
if that's preferred.
Testing
oxlint/oxfmtclean for the new app (with a.oxlintrc.jsonoverridefor the build script's dynamic import, following the existing pattern)
tsgo --noEmitpasses for both new programs (tsconfig.server.json,tsconfig.web.json)node build-server.mjs+vite buildproduce a working server bundle,16 worker bundles, and the renderer build
WebSocket connect + auth, wire procedure calls, live model snapshots,
project creation, and the renderer booting against the served build
(
apps/emdash-web/scripts/smoke.mjsautomates the wire checks)critical path; I can add Vitest coverage for the gateway/static handler
if the direction is welcome
Screenshot/Recording (if applicable)
Can attach a recording of the web UI running the full task flow on request.
Checklist