diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 91efc5d3..2faf4ebc 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -1364,6 +1364,67 @@ Object.assign(CodemanApp.prototype, {
}
},
+ /**
+ * Settings → System → Diagnostics: run `codeman doctor` on the server (GET /api/doctor) and list
+ * each tool. Built with DOM nodes and textContent: paths and versions come from the host.
+ */
+ async runDoctor() {
+ const out = document.getElementById('doctorResult');
+ const btn = document.getElementById('doctorRunBtn');
+ if (!out) return;
+ const say = (text) => {
+ out.replaceChildren(document.createTextNode(text));
+ out.style.display = 'block';
+ };
+ if (btn) btn.disabled = true;
+ say('Checking…');
+ try {
+ const res = await this._api('/api/doctor');
+ let body = null;
+ try { body = res ? await res.json() : null; } catch { /* fall through */ }
+ if (!res || !res.ok || !body || body.success === false) {
+ say(body?.error || 'The check failed.');
+ return;
+ }
+ const { tools, summary, platform } = body.data;
+ const glyph = { ok: '✓', missing: '✗', outdated: '!', error: '!', skipped: '–' };
+ const list = document.createElement('ul');
+ list.style.margin = '0';
+ list.style.paddingLeft = '1.2em';
+ for (const t of tools) {
+ const li = document.createElement('li');
+ const strong = document.createElement('b');
+ strong.textContent = `${glyph[t.status] || '?'} ${t.label}`;
+ li.append(strong);
+ const bits = [t.status];
+ if (t.version) bits.push(t.version);
+ if (t.status !== 'ok' && t.status !== 'skipped') bits.push(t.required ? 'required' : 'optional');
+ if (t.reason) bits.push(t.reason);
+ li.append(document.createTextNode(` ${bits.join(' · ')}`));
+ if (t.path) {
+ const p = document.createElement('div');
+ p.className = 'mono';
+ p.textContent = t.path;
+ li.append(p);
+ }
+ if (t.status === 'missing' && t.installHint) {
+ const h = document.createElement('div');
+ h.textContent = `Install: ${t.installHint}`;
+ li.append(h);
+ }
+ list.append(li);
+ }
+ const head = document.createElement('p');
+ head.textContent =
+ `${summary.ok} ok · ${summary.requiredMissing} required missing · ${summary.optionalMissing} optional missing` +
+ ` (${platform.environment})`;
+ out.replaceChildren(head, list);
+ out.style.display = 'block';
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+ },
+
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }
diff --git a/src/web/routes/doctor-routes.ts b/src/web/routes/doctor-routes.ts
new file mode 100644
index 00000000..611e2238
--- /dev/null
+++ b/src/web/routes/doctor-routes.ts
@@ -0,0 +1,85 @@
+/**
+ * @fileoverview `GET /api/doctor` — the `codeman doctor` dependency report (Node, the agent CLIs,
+ * tmux, LibreOffice, MS Office) for Settings → System → Diagnostics.
+ *
+ * The probe engine is synchronous (`which` + `
--version` per tool, each up to its own
+ * timeout), so it must never run on the server's event loop: a handful of slow probes would
+ * freeze every request and every SSE client, with the process still alive. The default runner
+ * therefore runs `codeman doctor --json` in a CHILD PROCESS of this same entry script and
+ * parses its output; the runner is injected so tests never spawn anything.
+ *
+ * Read-only, but the report names install paths and versions on the host, so in multi-user
+ * mode it is admin only (the same bar as the other host-introspection routes).
+ */
+
+import { execFile } from 'node:child_process';
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
+import { isAdmin } from '../route-helpers.js';
+import { isMultiUserMode } from '../../config/multiuser.js';
+import { TOOL_CATEGORIES } from '../../config/dependency-registry.js';
+import type { DependencyReportJson } from '../../utils/dependency-report.js';
+
+export type DoctorRunner = (category?: string) => Promise;
+
+const DOCTOR_TIMEOUT_MS = 30_000;
+
+function isReport(v: unknown): v is DependencyReportJson {
+ const r = v as Partial | null;
+ return !!r && Array.isArray(r.tools) && typeof r.summary === 'object' && r.summary !== null;
+}
+
+/**
+ * Run `doctor --json` out of process. The CLI exits non-zero when a required tool is missing,
+ * and still prints the report, so a non-zero exit with parseable stdout is a normal result.
+ */
+export const defaultDoctorRunner: DoctorRunner = (category) =>
+ new Promise((resolve, reject) => {
+ const args = [
+ ...process.execArgv,
+ process.argv[1],
+ 'doctor',
+ '--json',
+ ...(category ? ['--category', category] : []),
+ ];
+ execFile(
+ process.execPath,
+ args,
+ { timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env },
+ (err, stdout) => {
+ try {
+ const parsed: unknown = JSON.parse(stdout);
+ if (isReport(parsed)) return resolve(parsed);
+ } catch {
+ /* fall through to the error below */
+ }
+ reject(err ?? new Error('doctor produced no report'));
+ }
+ );
+ });
+
+export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void {
+ app.get(
+ '/api/doctor',
+ async (req: FastifyRequest, reply: FastifyReply): Promise> => {
+ if (isMultiUserMode() && !isAdmin(req)) {
+ reply.code(403);
+ return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
+ }
+ const { category } = req.query as { category?: string };
+ if (category !== undefined && !(TOOL_CATEGORIES as readonly string[]).includes(category)) {
+ reply.code(400);
+ return createErrorResponse(
+ ApiErrorCode.INVALID_INPUT,
+ `Unknown category "${category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`
+ );
+ }
+ try {
+ return { success: true, data: await runner(category) };
+ } catch (err) {
+ reply.code(500);
+ return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `doctor failed: ${getErrorMessage(err)}`);
+ }
+ }
+ );
+}
diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts
index a8886041..97041b41 100644
--- a/src/web/routes/index.ts
+++ b/src/web/routes/index.ts
@@ -30,6 +30,7 @@ export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-rout
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
export { registerMcpSyncRoutes } from './mcp-sync-routes.js';
export { registerWebhookRoutes } from './webhook-routes.js';
+export { registerDoctorRoutes } from './doctor-routes.js';
export {
registerCustomModelRoutes,
refreshAllCustomModelHosts,
diff --git a/src/web/server.ts b/src/web/server.ts
index b9f40556..d1d3b98a 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -202,6 +202,7 @@ import {
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerWebhookRoutes,
+ registerDoctorRoutes,
registerCustomModelRoutes,
refreshAllCustomModelHosts,
readCustomModelEndpointsEnabled,
@@ -1143,6 +1144,7 @@ export class WebServer extends EventEmitter {
configDir: getDataDir(),
hostTitle: () => this.windowTitle,
});
+ registerDoctorRoutes(this.app);
registerCustomModelRoutes(this.app);
registerCliRegistryRoutes(this.app);
diff --git a/test/doctor-cli-json.test.ts b/test/doctor-cli-json.test.ts
new file mode 100644
index 00000000..72d6aa78
--- /dev/null
+++ b/test/doctor-cli-json.test.ts
@@ -0,0 +1,36 @@
+// @vitest-environment node
+// The contract GET /api/doctor's default runner relies on: the same entry script, given
+// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits
+// non-zero because something required is missing.
+
+import { execFile } from 'node:child_process';
+import { join } from 'node:path';
+import { describe, expect, it } from 'vitest';
+
+const ROOT = join(import.meta.dirname, '..');
+
+describe('codeman doctor --json', () => {
+ it('prints a report that includes Node and a summary, whatever the exit code', async () => {
+ const stdout = await new Promise((resolve, reject) => {
+ execFile(
+ process.execPath,
+ [
+ join(ROOT, 'node_modules/tsx/dist/cli.mjs'),
+ join(ROOT, 'src/index.ts'),
+ 'doctor',
+ '--json',
+ '--category',
+ 'core',
+ ],
+ { timeout: 60_000, cwd: ROOT },
+ (err, out) => (out ? resolve(out) : reject(err ?? new Error('no output')))
+ );
+ });
+ const report = JSON.parse(stdout);
+ expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/);
+ expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) }));
+ const node = report.tools.find((t: { id: string }) => t.id === 'node');
+ expect(node?.status).toBe('ok');
+ expect(report.tools.every((t: { category: string }) => t.category === 'core')).toBe(true);
+ }, 90_000);
+});
diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts
new file mode 100644
index 00000000..04974648
--- /dev/null
+++ b/test/doctor-settings.browser.test.ts
@@ -0,0 +1,94 @@
+/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { chromium, type Browser, type Page } from 'playwright';
+import { WebServer } from '../src/web/server.js';
+
+const PORT = 3196;
+
+const REPORT = {
+ platform: { environment: 'linux' },
+ summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
+ tools: [
+ {
+ id: 'node',
+ label: 'Node.js',
+ category: 'core',
+ required: true,
+ usedBy: [],
+ status: 'ok',
+ version: '22.1.0',
+ path: '/usr/bin/node',
+ },
+ {
+ id: 'tmux',
+ label: 'tmux',
+ category: 'core',
+ required: true,
+ usedBy: [],
+ status: 'missing',
+ installHint: 'apt install tmux',
+ },
+ // Host-supplied strings must be rendered as text, never as markup.
+ {
+ id: 'x',
+ label: '
',
+ category: 'other',
+ required: false,
+ usedBy: [],
+ status: 'missing',
+ },
+ ],
+};
+
+describe('Diagnostics panel in a real browser', () => {
+ let server: WebServer;
+ let browser: Browser;
+ let page: Page;
+
+ beforeAll(async () => {
+ server = new WebServer(PORT, false, true);
+ await server.start();
+ browser = await chromium.launch({ headless: true });
+ page = await browser.newPage();
+ await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
+ await page.evaluate(() => (window as any).app.openAppSettings());
+ }, 90000);
+
+ afterAll(async () => {
+ if (browser) await browser.close();
+ if (server) await server.stop();
+ }, 60000);
+
+ it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => {
+ await page.route('**/api/doctor', (route) =>
+ route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) })
+ );
+ await page.click('#doctorRunBtn');
+ await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? ''));
+ const text = await page.textContent('#doctorResult');
+ expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)');
+ expect(text).toContain('✓ Node.js ok · 22.1.0');
+ expect(text).toContain('/usr/bin/node');
+ expect(text).toContain('✗ tmux missing · required');
+ expect(text).toContain('Install: apt install tmux');
+ expect(text).toContain('
'); // shown literally
+ expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
+ expect(await page.$('#doctorResult img')).toBeNull();
+ expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
+ });
+
+ it('shows the server’s message when the check fails, and re-enables the button', async () => {
+ await page.unroute('**/api/doctor');
+ await page.route('**/api/doctor', (route) =>
+ route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }),
+ })
+ );
+ await page.click('#doctorRunBtn');
+ await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? ''));
+ expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
+ });
+});
diff --git a/test/routes/doctor-routes.test.ts b/test/routes/doctor-routes.test.ts
new file mode 100644
index 00000000..af965c58
--- /dev/null
+++ b/test/routes/doctor-routes.test.ts
@@ -0,0 +1,119 @@
+/**
+ * @fileoverview GET /api/doctor: the `codeman doctor` report for Settings → System → Diagnostics.
+ * The route runs the probe out of process (the engine is synchronous), so every test injects the
+ * runner; the default runner's parsing is covered against a faked `execFile`, and the CLI contract
+ * it relies on is exercised for real in test/doctor-cli-json.test.ts.
+ *
+ * Port: N/A (app.inject()).
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { createRouteTestHarness } from './_route-test-utils.js';
+import { defaultDoctorRunner, registerDoctorRoutes, type DoctorRunner } from '../../src/web/routes/doctor-routes.js';
+import type { DependencyReportJson } from '../../src/utils/dependency-report.js';
+
+const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
+vi.mock('node:child_process', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, execFile: execFileMock };
+});
+
+const REPORT: DependencyReportJson = {
+ platform: { environment: 'linux' },
+ summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
+ tools: [
+ { id: 'node', label: 'Node.js', category: 'core', required: true, usedBy: [], status: 'ok', version: '22.1.0' },
+ { id: 'tmux', label: 'tmux', category: 'core', required: true, usedBy: [], status: 'missing' },
+ ],
+};
+
+afterEach(() => {
+ delete process.env.CODEMAN_MULTIUSER;
+ execFileMock.mockReset();
+});
+
+describe('GET /api/doctor', () => {
+ it('returns the runner’s report in the success envelope', async () => {
+ const runner = vi.fn(async () => REPORT);
+ const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
+ const res = await app.inject({ method: 'GET', url: '/api/doctor' });
+ expect(res.statusCode).toBe(200);
+ expect(res.json()).toEqual({ success: true, data: REPORT });
+ expect(runner).toHaveBeenCalledWith(undefined);
+ });
+
+ it('passes a valid category through and rejects an unknown one without running anything', async () => {
+ const runner = vi.fn(async () => REPORT);
+ const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
+ expect((await app.inject({ method: 'GET', url: '/api/doctor?category=office' })).statusCode).toBe(200);
+ expect(runner).toHaveBeenLastCalledWith('office');
+ runner.mockClear();
+ const bad = await app.inject({ method: 'GET', url: '/api/doctor?category=%3Brm%20-rf' });
+ expect(bad.statusCode).toBe(400);
+ expect(bad.json().errorCode).toBe('INVALID_INPUT');
+ expect(runner).not.toHaveBeenCalled();
+ });
+
+ it('answers 500 with a message when the runner fails', async () => {
+ const runner: DoctorRunner = async () => {
+ throw new Error('spawn blew up');
+ };
+ const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
+ const res = await app.inject({ method: 'GET', url: '/api/doctor' });
+ expect(res.statusCode).toBe(500);
+ expect(res.json().error).toContain('spawn blew up');
+ });
+
+ it('multi-user: a non-admin is refused and nothing is probed', async () => {
+ process.env.CODEMAN_MULTIUSER = '1';
+ const runner = vi.fn(async () => REPORT);
+ const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner), {
+ authUser: { username: 'bob', role: 'user' },
+ });
+ const res = await app.inject({ method: 'GET', url: '/api/doctor' });
+ expect(res.statusCode).toBe(403);
+ expect(runner).not.toHaveBeenCalled();
+ });
+
+ it('multi-user: an admin is allowed', async () => {
+ process.env.CODEMAN_MULTIUSER = '1';
+ const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, async () => REPORT), {
+ authUser: { username: 'root', role: 'admin' },
+ });
+ expect((await app.inject({ method: 'GET', url: '/api/doctor' })).statusCode).toBe(200);
+ });
+});
+
+describe('defaultDoctorRunner', () => {
+ type Done = (err: Error | null, stdout: string) => void;
+ const respond = (err: Error | null, stdout: string) =>
+ execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, done: Done) => done(err, stdout));
+
+ it('runs `doctor --json` in a child of this same entry script, never in-process', async () => {
+ respond(null, JSON.stringify(REPORT));
+ await defaultDoctorRunner('core');
+ const [bin, args, opts] = execFileMock.mock.calls[0];
+ expect(bin).toBe(process.execPath);
+ expect(args.slice(-4)).toEqual(['doctor', '--json', '--category', 'core']);
+ expect(args).toContain(process.argv[1]);
+ expect((opts as { timeout: number }).timeout).toBeGreaterThan(0);
+ });
+
+ it('treats a non-zero exit with a valid report as a normal result (a missing required tool exits 1)', async () => {
+ respond(Object.assign(new Error('exit 1'), { code: 1 }), JSON.stringify(REPORT));
+ await expect(defaultDoctorRunner()).resolves.toEqual(REPORT);
+ });
+
+ it.each([
+ ['empty output', ''],
+ ['non-JSON output', 'Segmentation fault'],
+ ['JSON of the wrong shape', '{"hello":"world"}'],
+ ])('rejects %s', async (_label, stdout) => {
+ respond(null, stdout);
+ await expect(defaultDoctorRunner()).rejects.toThrow();
+ });
+
+ it('passes the child’s own error through when there is no report at all', async () => {
+ respond(new Error('ETIMEDOUT'), '');
+ await expect(defaultDoctorRunner()).rejects.toThrow('ETIMEDOUT');
+ });
+});