fix(deepseek): make the web-UI shortcut pick a free port, verify it, and trust its frame

The `Run > DeepSeek web UI...` shortcut failed three ways at once against a real
install, and the three are independent.

1. It hardcoded `--port 3080`. That is dsh web's OWN default, which makes it
   precisely the port a DeepSeek user is most likely to be serving on already,
   so the launch died with EADDRINUSE against the user's own server. The port
   now comes from `GET /api/deepseek/web-port`, which walks 3080..3119 for a
   free loopback port by BINDING it (a connect probe cannot tell "free" from
   "listening but not answering yet").

2. It opened the tab unconditionally. The crashed server left a saved dashboard
   pointing at nothing, with the failure only visible in a shell tab nobody had
   a reason to look at. The launch now polls the existing webview probe until
   the URL answers, and on timeout reports the error naming the shell tab
   instead of persisting a dead dashboard.

3. The saved tab was untrusted, so the frame was sandboxed without
   `allow-same-origin` and the dashboard was broken twice over: the dsh
   client-runtime reads `localStorage` while loading its plugins and died there
   ("the document is sandboxed and lacks the 'allow-same-origin' flag"), and an
   opaque-origin frame sends `Origin: null`, so dsh's own trust fence 403'd
   every `/api` call no matter which authority `--trusted-host` named. Passing
   `location.host` only means anything once the frame actually carries that
   origin, so `--trusted-host` had never once done its job. The managed tab is
   now created `trusted: true`.

   That trade is real and deliberate: a trusted proxied frame is same-origin
   with Codeman and can reach Codeman's API. It is defensible only because this
   dashboard is an agent harness Codeman just started itself, on loopback, which
   can already run code as the user. It is not a precedent for trusting
   third-party dashboards, which is why it is set at this one call site rather
   than defaulted.

Separately, the shortcut listed its own dashboard twice: once as the menu entry
that starts it and once as the row that entry had written on the previous click.
Webviews now carry an optional `managed` marker, managed rows are filtered out
of the saved-dashboard list, and a relaunch repoints the existing row rather
than stacking one dead dashboard per restart (which the per-launch port would
otherwise guarantee). `managed` is declared in the schema because a plain
`z.object` strips undeclared keys, so an undeclared marker would never survive
the round trip.

`DEEPSEEK_WEB_PORT` is gone from constants.js; its doc comment asserted that a
hand-started `dsh web` and the shortcut "land on the same place and share one
saved tab", which is the bug stated as a feature.

Verified on a real install with the user's own `dsh web` holding 3080: the
shortcut takes 3081, the server answers, exactly one DeepSeek entry shows in the
run menu, and the proxied dashboard renders its workspaces and completes its own
API calls (the previously-403'd `api/settings.describe` now succeeds). Full gate
green (6142 passed), typecheck/lint/format/public-assets clean.
This commit is contained in:
Codeman maintainer
2026-08-25 02:39:57 +02:00
parent cdceede33d
commit 15ae5f5d81
8 changed files with 211 additions and 25 deletions
+45
View File
@@ -12,6 +12,7 @@ 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';
@@ -68,6 +69,35 @@ 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;
@@ -511,6 +541,21 @@ export function registerSystemRoutes(
};
});
// First free loopback port for a `dsh web` the UI is about to start.
//
// 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 } };
}
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}`
);
});
// Bootstrap an interactive profile so the mode becomes usable.
//
// This exists because DeepSeek ships NO terminal front door: `dsh` on its own
+1
View File
@@ -132,6 +132,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
// dashboard on an HTTPS Codeman, which is the common case.
embedMode: input.embedMode ?? 'proxy',
trusted: input.trusted ?? false,
managed: input.managed,
owner,
createdAt: Date.now(),
};