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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
This commit is contained in:
Devvyn
2026-10-05 09:11:10 +08:00
co-authored by Claude Sonnet 5.5
parent 1b89d7a387
commit db9a39405b
8 changed files with 143 additions and 9 deletions
+17
View File
@@ -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 (`<dir>/<bin>`) 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),
},
},
],
+15 -3
View File
@@ -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
+14
View File
@@ -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?.();
});
+17 -2
View File
@@ -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<string, Promise<DependencyReportJson>>();
const runShared = (category?: string): Promise<DependencyReportJson> => {
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<ApiResponse<DependencyReportJson>> => {
@@ -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)}`);
}
}
);