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
+100 -14
View File
@@ -507,23 +507,54 @@ Object.assign(CodemanApp.prototype, {
* browser-trust check on the request authority, and a Codeman web tab reaches
* it through Codeman's own origin via the webview proxy, not directly. Without
* passing Codeman's authority the page renders and every API call fails.
*
* The tab is saved `trusted: true`, and that is REQUIRED rather than a
* convenience: an untrusted webview is sandboxed without `allow-same-origin`,
* which breaks this dashboard twice over. The dsh client-runtime reads
* `localStorage` while loading its plugins and dies 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 check 403s every `/api` call
* no matter which authority `--trusted-host` names. Passing `location.host`
* only means anything once the frame actually carries that origin.
*
* The trade this makes is real and worth stating: a trusted proxied frame is
* same-origin with Codeman and can therefore reach Codeman's own API. It is
* defensible only because of what this specific dashboard already is - an
* agent harness Codeman just started itself, on loopback, which can run code
* as the user regardless. It is not a precedent for trusting third-party
* dashboards generally, which is why it is set here rather than defaulted.
*/
async runDeepSeekWeb() {
document.getElementById('runModeMenu')?.classList.remove('active');
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const port = DEEPSEEK_WEB_PORT;
const url = `http://127.0.0.1:${port}`;
const sessionName = `dsh-web-${caseName}`;
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting the DeepSeek web UI in ${caseName}...`);
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'shell',
sessionName: `dsh-web-${caseName}`,
}),
body: JSON.stringify({ caseName, mode: 'shell', sessionName }),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start the shell session');
@@ -540,28 +571,83 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({ input: `${cmd}\r` }),
});
// Reuse a saved tab for the same URL rather than stacking duplicates every
// time the server is restarted.
let webview = [...(this.webviews?.values() || [])].find((w) => w.url === url);
if (!webview) {
// 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.`);
}
// 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;
if (webview) {
const patchRes = await fetch(`/api/webviews/${webview.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, trusted: true }),
});
const patchData = await patchRes.json();
if (!patchData.success) throw new Error(patchData.error || 'Failed to update the web tab');
webview = patchData.data.webview || patchData.data;
} else {
const wvRes = await fetch('/api/webviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'DeepSeek Harness', url, icon: '🐳' }),
body: JSON.stringify({
name: 'DeepSeek Harness',
url,
icon: '\u{1F433}',
managed: 'deepseek-web',
trusted: true,
}),
});
const wvData = await wvRes.json();
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
webview = wvData.data.webview || wvData.data;
await this.loadWebviews?.();
}
await this.loadWebviews?.();
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} — opening it as a tab.`);
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`);
if (webview?.id) await this.openWebview(webview.id);
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
/**
* 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.
*