fix(deepseek): run the web UI server in the background, not in a shell tab

Clicking "DeepSeek web UI..." opened two tabs: the web tab asked for, and a
shell tab running the server next to it. The shell was deliberate - the server
lived in an ordinary session so it was visible, scrollable, killable and died
with its tab, and nothing new had to supervise a long-lived HTTP server. That
reasoning was sound and the result was still wrong in use: opening a dashboard
should open one tab, and after the first launch the terminal is pure noise.

The server moves to a background child process owned by a new
`src/deepseek-web-server.ts`, behind `POST /api/deepseek/web`. What the session
gave away for free is now explicit, which is most of the module:

- Exactly one server. A second click reuses the running one instead of racing
  it for a port; the session flow could not do this at all, because two clicks
  were simply two sessions.
- Restarted when the requested authority changes. `--trusted-host` fences dsh's
  own /api against the browser authority, and a Codeman reachable at both
  loopback and a tailnet name has two. Reusing a server fenced for the other
  origin renders a page whose every call 403s, which reads as a broken
  dashboard rather than a misconfigured one, so a mismatch restarts instead.
- Killed on shutdown. The child is detached so its whole plugin tree can be
  signalled at once, which also means it would outlive Codeman and hold its
  port against the next start - the exact EADDRINUSE this feature already got
  wrong once.
- Boot output captured and returned. With no shell tab there is nowhere else
  for a stack trace to land, so a failed spawn reports its own tail.

The endpoint is fenced at the same bar as the profile installer and for the
same reason: booting a dsh profile executes the plugin code in it, so this is a
privileged action even though it reads as "open a page". `authority` comes from
the client (`location.host`) because only the browser knows which origin is in
play, and it is regex-confined at the schema boundary - defence in depth behind
the argv-array spawn, admitting host:port in the shapes a browser authority can
take and nothing readable as a second argument.

`GET /api/deepseek/web-port` is gone; port selection moved into the supervisor,
which is the thing that knows whether a server is already running. The two
client-side probe helpers went with it, since the server now owns the wait.

Verified over the tailnet authority end to end: no session is created (session
count unchanged, one tab), the server runs on 3081 beside the user's own dsh
web on 3080, status reports the tailnet authority, and the proxied dashboard
renders with zero 4xx. Full gate green (6148 passed, +6).
This commit is contained in:
Codeman maintainer
2026-08-25 03:08:15 +02:00
parent 15ae5f5d81
commit c30dfaf0e7
7 changed files with 440 additions and 136 deletions
+22 -84
View File
@@ -497,11 +497,11 @@ Object.assign(CodemanApp.prototype, {
/**
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
*
* Deliberately built from parts that already exist rather than a new process
* manager: the server runs in an ordinary SHELL session, so it is visible,
* scrollable, killable and dies with its tab like anything else, and the UI
* itself is an ordinary web tab. Nothing here needs to know how to supervise a
* long-lived HTTP server, because Codeman already does.
* The server is a background child process owned by
* `deepseek-web-server.ts`, NOT a shell session. It was a shell session first,
* on the reasoning that Codeman already supervises those, and that version
* worked - it just put a terminal tab on screen beside the web tab the user
* actually asked for, on every click. Opening a dashboard should open one tab.
*
* `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a
* browser-trust check on the request authority, and a Codeman web tab reaches
@@ -526,63 +526,32 @@ Object.assign(CodemanApp.prototype, {
*/
async runDeepSeekWeb() {
document.getElementById('runModeMenu')?.classList.remove('active');
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const sessionName = `dsh-web-${caseName}`;
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`);
const ownsLaunchTerminal = this._beginSessionLaunchStatus('Starting the DeepSeek web UI...');
try {
// A server started by an earlier click may still be serving. Reusing it is
// what makes this entry idempotent: without the check, every click started
// a second `dsh web`, and the second one lost the port race.
const managed = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web');
if (managed && (await this._probeUrlReachable(managed.url))) {
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Already serving on ${managed.url} - opening it as a tab.`);
await this.openWebview(managed.id);
return;
}
// Never hardcode the port. 3080 is `dsh web`'s own default, which makes it
// precisely the port a DeepSeek user is most likely to be running already;
// binding it unconditionally killed the launch with EADDRINUSE while the
// tab still opened onto nothing.
const portRes = await fetch('/api/deepseek/web-port');
const portData = await portRes.json();
if (!portData.success) throw new Error(portData.error || 'No free port for the DeepSeek web UI');
const port = portData.data.port;
const url = `http://127.0.0.1:${port}`;
const res = await fetch('/api/quick-start', {
// One request, and the server owns everything behind it: picking a free
// port, spawning, waiting for the port to answer, and reusing an already
// running server instead of racing it. This used to start the server in a
// shell SESSION, which worked but put a terminal tab on screen next to the
// web tab actually asked for, every single time.
//
// `authority` is what dsh fences its own `/api` behind (`--trusted-host`),
// so it must be the origin this page is loaded from rather than anything
// the server could guess: a Codeman reachable at both loopback and a
// tailnet name has two, and only the browser knows which one is in play.
const startRes = await fetch('/api/deepseek/web', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'shell', sessionName }),
body: JSON.stringify({ authority: location.host }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start the shell session');
const sessionId = data.data.sessionId;
await this._ensureCreatedSessionVisible(sessionId, data.data.session);
// The shell needs a moment to reach its prompt before it will accept a
// command; the same settle the other shell-driven flows use.
await new Promise((r) => setTimeout(r, 1200));
const cmd = `dsh web --no-open --host 127.0.0.1 --port ${port} --trusted-host ${location.host}`;
await fetch(`/api/sessions/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: `${cmd}\r` }),
});
// Verify the server actually answers BEFORE persisting a tab for it. The
// tab used to open unconditionally, so a server that died on startup left
// a saved dashboard pointing at nothing and no hint as to why.
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Waiting for ${url} to answer...`);
if (!(await this._waitForUrlReachable(url))) {
throw new Error(`The DeepSeek web UI never answered on ${url} - see the "${sessionName}" tab for what it printed.`);
}
const startData = await startRes.json();
if (!startData.success) throw new Error(startData.error || 'Failed to start the DeepSeek web UI');
const url = startData.data.url;
// One managed record, repointed rather than duplicated: the port is chosen
// per launch, so creating a fresh row each time would stack a dashboard
// per restart, each pointing at a port nothing serves any more.
let webview = managed;
let webview = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web');
if (webview) {
const patchRes = await fetch(`/api/webviews/${webview.id}`, {
method: 'PATCH',
@@ -617,37 +586,6 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Server-side reachability check for a URL the browser is about to embed.
*
* Goes through the existing webview probe rather than `fetch(url)` from the
* page: a loopback dashboard is cross-origin to Codeman and would fail CORS
* long before it could report whether anything is listening.
*/
async _probeUrlReachable(url) {
try {
const res = await fetch('/api/webviews/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await res.json();
return !!(data.success && data.data?.reachable);
} catch {
return false;
}
},
/** Poll `_probeUrlReachable` until the server answers or the budget runs out. */
async _waitForUrlReachable(url, timeoutMs = 25000, intervalMs = 1000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await this._probeUrlReachable(url)) return true;
await new Promise((r) => setTimeout(r, intervalMs));
}
return false;
},
/**
* Install a DeepSeek Harness terminal profile from the run menu.
*