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.
*
+37 -40
View File
@@ -12,7 +12,6 @@ import fs from 'node:fs/promises';
import { totalmem, freemem, loadavg, cpus } from 'node:os';
import { execSync, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createServer } from 'node:net';
import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
@@ -28,6 +27,7 @@ import {
SubagentParentMapSchema,
RevokeSessionSchema,
DeepSeekInstallProfileSchema,
DeepSeekWebStartSchema,
} from '../schemas.js';
import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
@@ -70,34 +70,6 @@ import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui';
/**
* Where `GET /api/deepseek/web-port` starts looking, and how far it walks.
*
* 3080 is `dsh web`'s own default, so it is the friendly first choice — but it
* is emphatically NOT a fixed port. DeepSeek's web UI is a thing users run
* themselves, so the default is exactly the port most likely to be taken
* already, and hardcoding it made the shortcut die with EADDRINUSE against the
* user's own server while the tab still opened onto nothing.
*/
const DEEPSEEK_WEB_PORT_BASE = 3080;
const DEEPSEEK_WEB_PORT_SPAN = 40;
/**
* True when nothing holds `port` on the loopback interface.
*
* Binding is the only honest test: a connect probe cannot distinguish "free"
* from "listening but not answering yet", and this runs moments before `dsh web`
* binds the same port. The check is inherently racy, which is why the caller
* still verifies the server answered before it persists a tab for it.
*/
async function isLoopbackPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const probe = createServer();
probe.once('error', () => resolve(false));
probe.once('listening', () => probe.close(() => resolve(true)));
probe.listen(port, '127.0.0.1');
});
}
/** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */
const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000;
@@ -541,19 +513,44 @@ export function registerSystemRoutes(
};
});
// First free loopback port for a `dsh web` the UI is about to start.
// Start (or reuse) the background `dsh web` behind the Run menu shortcut.
//
// The browser cannot answer this: it can neither bind a port nor tell a closed
// one from a filtered one. Keeping the choice server-side also keeps it next
// to the process that will inherit it.
app.get('/api/deepseek/web-port', async () => {
for (let port = DEEPSEEK_WEB_PORT_BASE; port < DEEPSEEK_WEB_PORT_BASE + DEEPSEEK_WEB_PORT_SPAN; port++) {
if (await isLoopbackPortFree(port)) return { success: true, data: { port } };
// This runs as a plain child process rather than a shell SESSION on purpose.
// The session version worked, but it put a terminal tab on screen next to the
// web tab the user actually asked for, every single time. Nothing about a
// long-lived HTTP server needs to be a tab.
//
// 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".
app.post('/api/deepseek/web', async (req) => {
const { authority } = parseBody(DeepSeekWebStartSchema, req.body);
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Starting the DeepSeek web UI requires the can-bypass-permissions grant'
);
}
return createErrorResponse(
ApiErrorCode.INTERNAL_ERROR,
`No free port for the DeepSeek web UI in ${DEEPSEEK_WEB_PORT_BASE}-${DEEPSEEK_WEB_PORT_BASE + DEEPSEEK_WEB_PORT_SPAN - 1}`
);
const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js');
const dir = resolveDeepSeekDir();
if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage());
const { startDeepSeekWeb } = await import('../../deepseek-web-server.js');
const result = await startDeepSeekWeb(dir, authority);
if (!result.ok) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error);
return { success: true, data: { port: result.port, url: result.url, reused: result.reused } };
});
app.get('/api/deepseek/web', async () => {
const { getDeepSeekWebStatus } = await import('../../deepseek-web-server.js');
return { success: true, data: getDeepSeekWebStatus() };
});
app.delete('/api/deepseek/web', async () => {
const { stopDeepSeekWeb } = await import('../../deepseek-web-server.js');
await stopDeepSeekWeb();
return { success: true, data: { stopped: true } };
});
// Bootstrap an interactive profile so the mode becomes usable.
+20
View File
@@ -407,6 +407,26 @@ export const DeepSeekInstallProfileSchema = z
})
.strict();
/**
* POST /api/deepseek/web: start the background `dsh web` for one browser authority.
*
* `authority` becomes `--trusted-host`, which is what dsh fences its own `/api`
* behind, so it must be the origin the browser will actually load the tab from
* (`location.host`). It reaches a spawn as one element of an argv ARRAY, never a
* shell string, so this regex is defence in depth rather than the only guard: it
* admits host:port in the shapes a browser authority can take (dotted names,
* IPv4, bracketed IPv6) and nothing that could be read as a second argument.
*/
export const DeepSeekWebStartSchema = z
.object({
authority: z
.string()
.min(1)
.max(255)
.regex(/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/),
})
.strict();
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
+7
View File
@@ -95,6 +95,7 @@ import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
setupTimedRespawn,
@@ -3135,6 +3136,12 @@ export class WebServer extends EventEmitter {
this._dockerBridgeServer = null;
}
// The background `dsh web` 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.
void stopDeepSeekWeb();
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();