From d9174a7a03a93bb8038c24ff03384c7d4f56f9c4 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:05:55 +0800 Subject: [PATCH 1/5] feat(settings): codeman doctor in Settings -> System -> Diagnostics Co-Authored-By: Claude Sonnet 5.5 --- config/test-suites.ts | 1 + src/web/public/index.html | 14 ++++ src/web/public/settings-ui.js | 61 ++++++++++++++ src/web/routes/doctor-routes.ts | 85 +++++++++++++++++++ src/web/routes/index.ts | 1 + src/web/server.ts | 2 + test/doctor-cli-json.test.ts | 36 ++++++++ test/doctor-settings.browser.test.ts | 94 +++++++++++++++++++++ test/routes/doctor-routes.test.ts | 119 +++++++++++++++++++++++++++ 9 files changed, 413 insertions(+) create mode 100644 src/web/routes/doctor-routes.ts create mode 100644 test/doctor-cli-json.test.ts create mode 100644 test/doctor-settings.browser.test.ts create mode 100644 test/routes/doctor-routes.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 004af462..7566a063 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -35,6 +35,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', 'test/webhook-settings.browser.test.ts', + 'test/doctor-settings.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts', 'test/mobile-ime-preview.browser.test.ts', diff --git a/src/web/public/index.html b/src/web/public/index.html index cc8f0337..b05076bf 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2829,6 +2829,20 @@

System

Paths, automation and remote access. Set once, rarely touched.

+
+

Diagnostics

server
+
+
+
+ Check this machine + Runs codeman doctor on the server: which agent CLIs, tmux, Node and the optional office tools are installed, their versions, and how to install what is missing. +
+ +
+ +
+
+

Paths

synced
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'); + }); +}); From 1b89d7a387e7b3c7c63f972160c166576344829b Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:35:05 +0800 Subject: [PATCH 2/5] docs(doctor): api-reference and changeset Co-Authored-By: Claude Sonnet 5.5 --- .changeset/doctor-in-settings.md | 5 +++++ docs/api-reference.md | 4 ++++ 2 files changed, 9 insertions(+) create mode 100644 .changeset/doctor-in-settings.md diff --git a/.changeset/doctor-in-settings.md b/.changeset/doctor-in-settings.md new file mode 100644 index 00000000..b58e4bf8 --- /dev/null +++ b/.changeset/doctor-in-settings.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Settings → System → Diagnostics runs `codeman doctor` on the server (`GET /api/doctor`) and lists which agent CLIs, tmux, Node and the optional office tools are installed, their versions, paths and install hints. The probe runs in a child process so a slow `--version` can never freeze the server; admin only in multi-user mode. diff --git a/docs/api-reference.md b/docs/api-reference.md index 7b534ddc..13d31aa2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -748,6 +748,10 @@ Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Setting Delivery goes through the same egress guard as web tabs (refused on the resolved address too), does not follow redirects, times out after 5 s, sends the same event for the same session at most once per 3 s, and has at most 5 requests in flight. Error text never contains the URL. +## Diagnostics + +`GET /api/doctor[?category=core|office|other]` returns the `codeman doctor --json` report (`platform`, `summary`, `tools[]` with `status` `ok` \| `missing` \| `outdated` \| `skipped` \| `error`, `version`, `path`, `installHint`). The probe engine is synchronous, so it runs in a child process of the same entry script, never on the server's event loop (30 s timeout). It names install paths and versions, so it is admin only in multi-user mode (`403`). `400` for an unknown category, `500` if the child produces no report. + ## Voice dictation Browser dictation transcribed through this server's Claude Code login, i.e. the From db9a39405bcacebd27ed04790e1a0c085ed15014 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:11:10 +0800 Subject: [PATCH 3/5] fix(doctor): resolve CLIs via searchDirs, single-flight runs, admin-gate the group (#536 review) - doctor probes each CLI's discovery.searchDirs when which misses and runs --version on the resolved path, so a service with a minimal PATH no longer reports installed CLIs as missing - GET /api/doctor shares one in-flight run per category - Diagnostics group hidden from non-admins in multi-user mode (_applyDoctorAdminGate) - 500 uses INTERNAL_ERROR; a killed child reports 'timed out after 30 s' - browser test blocks service workers so page.route() is reliable - wiki: Diagnostics sentence Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS --- docs/wiki/Settings-Reference.md | 6 ++-- src/config/dependency-registry.ts | 17 ++++++++++ src/utils/dependency-checker.ts | 18 ++++++++-- src/web/public/settings-ui.js | 14 ++++++++ src/web/routes/doctor-routes.ts | 19 +++++++++-- test/dependency-checker.test.ts | 49 +++++++++++++++++++++++++++- test/doctor-settings.browser.test.ts | 4 ++- test/routes/doctor-routes.test.ts | 25 ++++++++++++++ 8 files changed, 143 insertions(+), 9 deletions(-) diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 4892d3be..d5904133 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -145,8 +145,10 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts ### System `CLAUDE.md` template for new cases, default working directory, the image watcher, and -Cloudflare tunnel controls including the tunnel and upload URLs. In multi-user mode, the -**Users** administration entry is injected here. +Cloudflare tunnel controls including the tunnel and upload URLs. The **Diagnostics** group runs +`codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office +tools with their versions and install hints (admin only in multi-user mode). In multi-user +mode, the **Users** administration entry is injected here. ## Session Options diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts index 96512a36..c7b96cdc 100644 --- a/src/config/dependency-registry.ts +++ b/src/config/dependency-registry.ts @@ -7,6 +7,8 @@ * @module config/dependency-registry */ +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { enabledClis } from './cli-registry/registry.js'; import { compileVersionRegex } from './cli-registry/patterns.js'; @@ -30,6 +32,20 @@ export interface PathResolver { * there and a false "installed" contradicts the run mode's own resolver. */ requireVersionMatch?: boolean; + /** + * Absolute directories to probe (`/`) when `which` misses. A service (systemd, + * launchd) runs with a minimal PATH, so a CLI installed under `~/.local/bin` or an npm/nvm + * prefix is invisible to `which` while the run mode, which falls back to the registry's + * `discovery.searchDirs`, still finds it. Carries those dirs so the doctor agrees. + */ + searchDirs?: string[]; +} + +/** Expand a leading `~` (the only form registry `searchDirs` use). */ +function expandSearchDir(dir: string): string { + if (dir === '~') return homedir(); + if (dir.startsWith('~/')) return join(homedir(), dir.slice(2)); + return dir; } /** Resolve a Windows-installed app reachable from win32 or WSL. */ @@ -131,6 +147,7 @@ function cliDependencyEntries(): ToolDependency[] { // (pi, grok, dsh): a bare `which` hit there is not evidence of the right // program, so a version mismatch means MISSING rather than unknown-version. requireVersionMatch: version?.requireVersionMatch, + searchDirs: cli.discovery.searchDirs.map(expandSearchDir), }, }, ], diff --git a/src/utils/dependency-checker.ts b/src/utils/dependency-checker.ts index 6d5c6b4a..6081078a 100644 --- a/src/utils/dependency-checker.ts +++ b/src/utils/dependency-checker.ts @@ -94,11 +94,23 @@ export function checkTool(tool: ToolDependency, host: ProbeHost): ToolResult { if (!spec) return { ...base, status: 'skipped', reason: `not applicable on ${host.environment}` }; if (spec.resolver.kind === 'path') { - const { bins, versionArg, versionRegex, requireVersionMatch } = spec.resolver; + const { bins, versionArg, versionRegex, requireVersionMatch, searchDirs } = spec.resolver; for (const bin of bins) { - const resolved = host.which(bin); + // `which` first (the PATH), then the registry's search dirs: under a service the PATH is + // minimal and the run mode finds the CLI through those dirs, so the doctor must too. + let resolved = host.which(bin); + if (!resolved && searchDirs) { + for (const dir of searchDirs) { + const candidate = `${dir.replace(/\/+$/, '')}/${bin}`; + if (host.fileExists(candidate)) { + resolved = candidate; + break; + } + } + } if (resolved) { - const out = host.runVersion(bin, [versionArg ?? '--version']); + // Run the RESOLVED path: a bare name would miss the same binary `which` just missed. + const out = host.runVersion(resolved, [versionArg ?? '--version']); const version = out ? extractVersion(out, versionRegex) : undefined; // A generic binary name that prints the wrong thing is some OTHER program (see // PathResolver.requireVersionMatch). Keep looking, then report MISSING; the diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 2faf4ebc..dbe66cf3 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -422,6 +422,7 @@ Object.assign(CodemanApp.prototype, { this._mcpSyncSavedOn = settings.mcpSyncEnabled === true; document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn; this.applyMcpSyncVisibility(); + this._applyDoctorAdminGate(); this.loadWebhook(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; @@ -1192,6 +1193,18 @@ Object.assign(CodemanApp.prototype, { group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; }, + /** + * GET /api/doctor is admin-only in multi-user mode (it names install paths on the host), so a + * non-admin gets no Diagnostics group instead of a button that can only answer 403. Also + * wired to `codeman:me` for the same late-resolving role as the groups above. + */ + _applyDoctorAdminGate() { + const group = document.getElementById('doctorGroup'); + if (!group) return; + const me = window.__codemanUser || {}; + group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; + }, + /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ async mcpSync(apply) { const out = this.$('mcpSyncResult'); @@ -4434,4 +4447,5 @@ document.addEventListener?.('codeman:me', () => { window.app?._applyCustomModelAdminGate?.(); window.app?._applyCliManagementAdminGate?.(); window.app?._applyMcpSyncAdminGate?.(); + window.app?._applyDoctorAdminGate?.(); }); diff --git a/src/web/routes/doctor-routes.ts b/src/web/routes/doctor-routes.ts index 611e2238..c835224e 100644 --- a/src/web/routes/doctor-routes.ts +++ b/src/web/routes/doctor-routes.ts @@ -47,6 +47,9 @@ export const defaultDoctorRunner: DoctorRunner = (category) => args, { timeout: DOCTOR_TIMEOUT_MS, maxBuffer: 1024 * 1024, env: process.env }, (err, stdout) => { + if (err && (err as { killed?: boolean }).killed) { + return reject(new Error(`timed out after ${DOCTOR_TIMEOUT_MS / 1000} s`)); + } try { const parsed: unknown = JSON.parse(stdout); if (isReport(parsed)) return resolve(parsed); @@ -59,6 +62,18 @@ export const defaultDoctorRunner: DoctorRunner = (category) => }); export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner = defaultDoctorRunner): void { + // Each run forks a full Node process, so two tabs or a script must not stack them: callers + // asking for the same category while one is in flight share its promise. + const inFlight = new Map>(); + const runShared = (category?: string): Promise => { + const key = category ?? ''; + let running = inFlight.get(key); + if (!running) { + running = runner(category).finally(() => inFlight.delete(key)); + inFlight.set(key, running); + } + return running; + }; app.get( '/api/doctor', async (req: FastifyRequest, reply: FastifyReply): Promise> => { @@ -75,10 +90,10 @@ export function registerDoctorRoutes(app: FastifyInstance, runner: DoctorRunner ); } try { - return { success: true, data: await runner(category) }; + return { success: true, data: await runShared(category) }; } catch (err) { reply.code(500); - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `doctor failed: ${getErrorMessage(err)}`); + return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, `doctor failed: ${getErrorMessage(err)}`); } } ); diff --git a/test/dependency-checker.test.ts b/test/dependency-checker.test.ts index 70e69bd5..978bbc62 100644 --- a/test/dependency-checker.test.ts +++ b/test/dependency-checker.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { dependencyRegistry } from '../src/config/dependency-registry.js'; import { detectEnvironment, @@ -259,6 +259,53 @@ describe('checkTool with requireVersionMatch (generic binary names)', () => { }); }); +describe('checkTool with searchDirs (service PATH is minimal)', () => { + const claudeLike: ToolDependency = { + ...tmuxTool, + id: 'claude', + label: 'Claude CLI', + resolvers: [ + { + match: ['linux'], + resolver: { kind: 'path', bins: ['claude'], searchDirs: ['/home/u/.local/bin', '/opt/npm/bin/'] }, + }, + ], + }; + + it('finds a CLI that only lives in a searchDirs entry and runs --version on the absolute path', () => { + const runVersion = vi.fn(() => 'claude 2.1.0'); + const host = fakeHost('linux', { fileExists: (p) => p === '/opt/npm/bin/claude', runVersion }); + expect(checkTool(claudeLike, host)).toMatchObject({ + status: 'ok', + path: '/opt/npm/bin/claude', + version: '2.1.0', + }); + expect(runVersion).toHaveBeenCalledWith('/opt/npm/bin/claude', ['--version']); + }); + + it('still reports missing when neither PATH nor any search dir has it', () => { + expect(checkTool(claudeLike, fakeHost('linux'))).toMatchObject({ status: 'missing' }); + }); + + it('prefers the PATH hit over a search dir', () => { + const host = fakeHost('linux', { + which: () => '/usr/bin/claude', + fileExists: () => true, + runVersion: () => '1.0.0', + }); + expect(checkTool(claudeLike, host)).toMatchObject({ path: '/usr/bin/claude' }); + }); + + it('carries each enabled CLI’s expanded discovery.searchDirs onto its registry row', () => { + const rows = dependencyRegistry().flatMap((t) => t.resolvers.map((r) => r.resolver)); + const withDirs = rows.filter((r) => r.kind === 'path' && r.searchDirs?.length); + expect(withDirs.length).toBeGreaterThan(0); + for (const r of withDirs) { + if (r.kind === 'path') for (const d of r.searchDirs ?? []) expect(d.startsWith('~')).toBe(false); + } + }); +}); + describe('checkAll', () => { it('maps every tool to a result', () => { const results = checkAll([tmuxTool, msTool], fakeHost('linux')); diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts index 04974648..df7d6e6e 100644 --- a/test/doctor-settings.browser.test.ts +++ b/test/doctor-settings.browser.test.ts @@ -49,7 +49,9 @@ describe('Diagnostics panel in a real browser', () => { server = new WebServer(PORT, false, true); await server.start(); browser = await chromium.launch({ headless: true }); - page = await browser.newPage(); + // A controlling service worker can swallow requests before page.route() sees them, letting the + // real /api/doctor (a forked Node process) answer instead; block it so the stub is reliable. + page = await (await browser.newContext({ serviceWorkers: 'block' })).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()); diff --git a/test/routes/doctor-routes.test.ts b/test/routes/doctor-routes.test.ts index af965c58..1a6926d5 100644 --- a/test/routes/doctor-routes.test.ts +++ b/test/routes/doctor-routes.test.ts @@ -61,6 +61,26 @@ describe('GET /api/doctor', () => { const res = await app.inject({ method: 'GET', url: '/api/doctor' }); expect(res.statusCode).toBe(500); expect(res.json().error).toContain('spawn blew up'); + expect(res.json().errorCode).toBe('INTERNAL_ERROR'); + }); + + it('single-flights: concurrent requests for a category share one run, and a later one runs again', async () => { + const releases: Array<(r: DependencyReportJson) => void> = []; + const runner = vi.fn(() => new Promise((res) => releases.push(res))); + const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner)); + const first = app.inject({ method: 'GET', url: '/api/doctor' }); + const second = app.inject({ method: 'GET', url: '/api/doctor' }); + const other = app.inject({ method: 'GET', url: '/api/doctor?category=office' }); + await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(2)); + releases[0](REPORT); + expect((await first).statusCode).toBe(200); + expect((await second).statusCode).toBe(200); + expect(runner).toHaveBeenCalledTimes(2); // unfiltered (shared) + office + releases[1](REPORT); + await other; + runner.mockImplementation(async () => REPORT); + await app.inject({ method: 'GET', url: '/api/doctor' }); + expect(runner).toHaveBeenCalledTimes(3); }); it('multi-user: a non-admin is refused and nothing is probed', async () => { @@ -112,6 +132,11 @@ describe('defaultDoctorRunner', () => { await expect(defaultDoctorRunner()).rejects.toThrow(); }); + it('reports a killed child (the 30 s timeout) as a timeout, not the raw command line', async () => { + respond(Object.assign(new Error('Command failed: node doctor --json'), { killed: true, signal: 'SIGTERM' }), ''); + await expect(defaultDoctorRunner()).rejects.toThrow('timed out after 30 s'); + }); + 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'); From 294ce0a6674295dc5ac432e1c7a1067db2d28987 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:50:24 +0800 Subject: [PATCH 4/5] =?UTF-8?q?docs(doctor):=20README=20entry=20for=20Sett?= =?UTF-8?q?ings=20=E2=86=92=20System=20=E2=86=92=20Diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4869b0fd..0f6f5927 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt ## More Features - **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files +- **Diagnostics in Settings** — **App Settings → System → Diagnostics → Run checks** runs `codeman doctor` on the server and lists Node, tmux, every agent CLI and the optional office tools with versions, paths and install hints. CLIs are found the same way the Run menu finds them (including `~/.local/bin` and npm/nvm prefixes), so a service with a minimal `PATH` still reports them correctly. Admin only in multi-user mode. - **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable) - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials - **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md) From 4152ee10159c197799152a909c6f3dbbb040218e Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:56:10 +0800 Subject: [PATCH 5/5] test(doctor): minimal-PATH searchDirs regression and the non-admin gate Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS --- test/doctor-cli-json.test.ts | 43 +++++++++++++++++++++++++++- test/doctor-settings.browser.test.ts | 12 ++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/test/doctor-cli-json.test.ts b/test/doctor-cli-json.test.ts index 72d6aa78..64c04881 100644 --- a/test/doctor-cli-json.test.ts +++ b/test/doctor-cli-json.test.ts @@ -3,7 +3,9 @@ // `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 { execFile, execFileSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -33,4 +35,43 @@ describe('codeman doctor --json', () => { expect(node?.status).toBe('ok'); expect(report.tools.every((t: { category: string }) => t.category === 'core')).toBe(true); }, 90_000); + + // The report an operator got wrong in production: under systemd the PATH is minimal, so a CLI + // installed in ~/.local/bin read `missing` while the Run menu (which also searches the registry's + // searchDirs) found it. The PATH here holds nothing but `which`. + it('finds a CLI that lives only in a registry searchDirs entry when the PATH is minimal', async () => { + const home = mkdtempSync(join(tmpdir(), 'doctor-home-')); + const bare = mkdtempSync(join(tmpdir(), 'doctor-path-')); + try { + mkdirSync(join(home, '.local/bin'), { recursive: true }); + const fake = join(home, '.local/bin/claude'); + writeFileSync(fake, '#!/bin/sh\necho "2.1.0 (Claude Code)"\n'); + chmodSync(fake, 0o755); + symlinkSync(execFileSyncWhich(), join(bare, 'which')); + 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, env: { ...process.env, HOME: home, PATH: bare } }, + (err, out) => (out ? resolve(out) : reject(err ?? new Error('no output'))) + ); + }); + const claude = JSON.parse(stdout).tools.find((t: { id: string }) => t.id === 'claude'); + expect(claude).toMatchObject({ status: 'ok', path: fake }); + } finally { + rmSync(home, { recursive: true, force: true }); + rmSync(bare, { recursive: true, force: true }); + } + }, 90_000); }); + +function execFileSyncWhich(): string { + return execFileSync('sh', ['-c', 'command -v which'], { encoding: 'utf-8' }).trim(); +} diff --git a/test/doctor-settings.browser.test.ts b/test/doctor-settings.browser.test.ts index df7d6e6e..e1dc8ff8 100644 --- a/test/doctor-settings.browser.test.ts +++ b/test/doctor-settings.browser.test.ts @@ -93,4 +93,16 @@ describe('Diagnostics panel in a real browser', () => { await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? '')); expect(await page.isDisabled('#doctorRunBtn')).toBe(false); }); + + it('hides the Diagnostics group from a non-admin in multi-user mode and shows it to an admin', async () => { + const visible = (user: Record) => + page.evaluate((u) => { + (window as any).__codemanUser = u; + document.dispatchEvent(new CustomEvent('codeman:me')); + return getComputedStyle(document.getElementById('doctorGroup')!).display !== 'none'; + }, user); + expect(await visible({ multiUser: true, role: 'user' })).toBe(false); + expect(await visible({ multiUser: true, role: 'admin' })).toBe(true); + expect(await visible({ multiUser: false })).toBe(true); + }); });