mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user