From a53266ab95e31c1c7916e672d92dc234e96449dc Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 20 Sep 2026 08:00:14 -0400 Subject: [PATCH 1/3] docs(adr): record ADR-0051 publication in alpha.48 --- ...orted-peer-delegation-and-host-realignment.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/adr/0051-supported-peer-delegation-and-host-realignment.md b/docs/adr/0051-supported-peer-delegation-and-host-realignment.md index 86b20ea..5594db7 100644 --- a/docs/adr/0051-supported-peer-delegation-and-host-realignment.md +++ b/docs/adr/0051-supported-peer-delegation-and-host-realignment.md @@ -1,6 +1,7 @@ # ADR-0051 — Supported peer delegation and host realignment -- **Status:** Accepted; implemented locally, release not published +- **Status:** Implemented; published in `4.0.0-alpha.48` +- **Updated:** 2026-09-20 — reconciled publication status against the GitHub release and npm artifact - **Date:** 2026-09-10 - **Deciders:** Project maintainer, through the current design discussion - **Amends:** [ADR-0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) @@ -155,3 +156,16 @@ Regression evidence covers scope filtering, path-free projection, stale action rejection, selected-only removal, and the real transaction coordinator. Browser verification exercises the actual markup, filtering client and preview selection against deterministic evidence fixtures. + +### Publication evidence — 2026-09-20 + +PR [#217](https://github.com/pacphi/agentic-kit/pull/217), commit `8caa25f`, +shipped in [v4.0.0-alpha.48](https://github.com/pacphi/agentic-kit/releases/tag/v4.0.0-alpha.48). +GitHub published the prerelease on 2026-09-10 at 18:51:42 UTC; npm published +`@pacphi/agentic-kit@4.0.0-alpha.48` at 18:51:33 UTC. This is an alpha +prerelease publication, not a stable-release designation. + +The registry tarball's SHA-512 matched its published integrity value. Eight +implementation files matched the release tag byte-for-byte: the alignment +engine and CLI, run guard, setup, sync, status section, and Maintenance provider +and management projection. The previous “release not published” header was stale. From 7dfd982544e356f996da57fa50e3271189304e53 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 20 Sep 2026 08:00:14 -0400 Subject: [PATCH 2/3] feat(health): add scoped local and connected host checks --- src/lib/host-health-connected.mjs | 242 ++++++++++++++++ src/lib/host-health-evidence.mjs | 47 ++++ src/lib/host-readiness-local.mjs | 338 +++++++++++++++++++++++ src/lib/host-readiness-probes.mjs | 118 ++++++++ src/lib/host-readiness.mjs | 152 ++++++++++ src/lib/paths.mjs | 29 ++ tests/kit/host-health-connected.test.mjs | 234 ++++++++++++++++ tests/kit/host-health-evidence.test.mjs | 48 ++++ tests/kit/host-readiness-local.test.mjs | 195 +++++++++++++ tests/kit/host-readiness-probes.test.mjs | 76 +++++ tests/kit/host-readiness.test.mjs | 130 +++++++++ 11 files changed, 1609 insertions(+) create mode 100644 src/lib/host-health-connected.mjs create mode 100644 src/lib/host-health-evidence.mjs create mode 100644 src/lib/host-readiness-local.mjs create mode 100644 src/lib/host-readiness-probes.mjs create mode 100644 src/lib/host-readiness.mjs create mode 100644 tests/kit/host-health-connected.test.mjs create mode 100644 tests/kit/host-health-evidence.test.mjs create mode 100644 tests/kit/host-readiness-local.test.mjs create mode 100644 tests/kit/host-readiness-probes.test.mjs create mode 100644 tests/kit/host-readiness.test.mjs diff --git a/src/lib/host-health-connected.mjs b/src/lib/host-health-connected.mjs new file mode 100644 index 0000000..a5d270e --- /dev/null +++ b/src/lib/host-health-connected.mjs @@ -0,0 +1,242 @@ +// Explicit, bounded provider-inference checks. Never called by dashboard polling. +// Sources: native --help + https://code.claude.com/docs/en/cli-reference, +// https://developers.openai.com/codex/config-reference/, +// https://opencode.ai/docs/{cli,config,permissions}/. +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { resolveShim } from './exec.mjs'; +import { parseLocalJsonc } from './host-readiness-local.mjs'; + +const HOSTS = new Set(['claude', 'codex', 'opencode']); +const MAX_BUFFER = 256 * 1024; +const CODEX_REQUIRED_FEATURES = ['plugins', 'hooks', 'apps', 'shell_tool']; +const CODEX_FEATURES = ['plugins', 'remote_plugin', 'hooks', 'apps', 'shell_tool', + 'multi_agent', 'skill_mcp_dependency_install', 'browser_use', + 'computer_use', 'code_mode', 'code_mode_host', 'image_generation', 'workspace_dependencies']; +const REQUIRED_FLAGS = { + claude: ['--safe-mode', '--print', '--output-format', '--tools', '--strict-mcp-config', '--mcp-config', '--settings', '--no-session-persistence', '--disable-slash-commands', '--permission-mode'], + codex: ['--json', '--sandbox', '--ephemeral', '--disable'], + opencode: ['--pure', '--agent', '--format'], +}; + +// Keep subprocesses in an owned group and terminate the group on every exit +// path, including caller cancellation. No shell, detached background worker, +// raw diagnostics, or inference retry is permitted by this adapter. +async function runNative(command, args, { cwd, env, timeout, maxBuffer, input, signal }) { + if (signal?.aborted) return { code: 1, stdout: '', stderr: 'aborted' }; + const invocation = resolveShim(command, args, { env }); + if (!invocation.resolved) return { code: 1, stdout: '', stderr: 'No safe Windows invocation' }; + return new Promise((resolve) => { + let child; + try { child = spawn(invocation.command, invocation.args, { + cwd, env, shell: false, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'], + }); } catch { resolve({ code: 1, stdout: '', stderr: 'spawn unavailable' }); return; } + let stdout = '', stderr = '', bytes = 0, finished = false, failure = ''; + const kill = () => { + if (!child.pid) return; + if (process.platform !== 'win32') { + try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); } + } else { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', shell: false }); + killer.on('error', () => child.kill('SIGKILL')); + } + }; + const finish = (code) => { + if (finished) return; + finished = true; + clearTimeout(timer); clearTimeout(cleanup); + signal?.removeEventListener('abort', abort); + kill(); + resolve({ code: failure ? 1 : code ?? 1, stdout, stderr: failure || stderr }); + }; + let cleanup; + const stop = reason => { + if (finished || failure) return; + failure = reason; kill(); + cleanup = setTimeout(() => finish(1), 2000); + }; + const abort = () => stop('aborted'); + const timer = setTimeout(() => stop('timed out'), timeout); + signal?.addEventListener('abort', abort, { once: true }); + const collect = (chunk, errorStream) => { + bytes += chunk.length; + if (bytes > maxBuffer) { stop('maxBuffer exceeded'); return; } + if (errorStream) stderr += chunk.toString(); else stdout += chunk.toString(); + }; + child.stdout.on('data', chunk => collect(chunk, false)); + child.stderr.on('data', chunk => collect(chunk, true)); + child.on('error', () => { failure = 'spawn unavailable'; finish(1); }); + child.on('close', finish); + child.stdin.on('error', () => {}); + child.stdin.end(input); + if (signal?.aborted) abort(); + }); +} + +function receipt(state, reason, model) { + return { + state, reason, checkedAt: new Date().toISOString(), scope: 'provider-inference', + model: model || null, + integrations: { state: 'not-checked', reason: 'MCP integrations are excluded from this provider check; no connectivity claim is made.' }, + }; +} +function parse(text) { try { return JSON.parse(text); } catch { return null; } } +function object(value) { return value && typeof value === 'object' && !Array.isArray(value); } +function safeId(value) { return typeof value === 'string' && /^[a-zA-Z0-9_.:/-]{1,200}$/.test(value) && !value.startsWith('-'); } +function safeModel(value) { return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.:/-]{0,199}(?:\[1m\])?$/.test(value); } +function flagsPresent(help, flags) { return flags.every(flag => new RegExp(`(^|[\\s,])${flag}(?=[\\s,=]|$)`, 'm').test(help)); } + +async function codexIsolation(invoke) { + const features = await invoke('codex', ['features', 'list']); + if (features.code !== 0) return null; + const present = new Set(features.stdout.split('\n').map(line => line.trim().split(/\s+/)[0])); + if (!CODEX_REQUIRED_FEATURES.every(name => present.has(name))) return null; + // Missing optional features are absent capabilities, not failed health. + // unified_exec is intentionally omitted: recent hosts pin it on; the + // shell_tool gate and read-only sandbox bound shell authority instead. + const selectedFeatures = CODEX_FEATURES.filter(name => present.has(name)); + const args = selectedFeatures.flatMap(name => ['--disable', name]); + const verifiedFeatures = await invoke('codex', ['features', 'list', ...args]); + if (verifiedFeatures.code !== 0 || !selectedFeatures.every(name => + new RegExp(`^${name}\\s+.+\\s+false$`, 'm').test(verifiedFeatures.stdout))) return null; + const listed = await invoke('codex', ['mcp', 'list', '--json', ...args]); + const servers = parse(listed.stdout); + if (listed.code !== 0 || !Array.isArray(servers) || servers.length > 200 + || servers.some(server => typeof server?.name !== 'string' || !/^[a-zA-Z0-9_-]{1,200}$/.test(server.name))) return null; + // Codex's dotted CLI override parser treats quoted components literally, + // unlike a TOML file. Only validated bare keys are safe here. + for (const server of servers) args.push('-c', `mcp_servers.${server.name}.enabled=false`); + // Verify that the native effective roster actually honors the overrides. + const verified = await invoke('codex', ['mcp', 'list', '--json', ...args]); + const disabled = parse(verified.stdout); + if (verified.code !== 0 || !Array.isArray(disabled) + || disabled.some(server => server?.enabled !== false)) return null; + return args; +} + +function denyOnly(value) { + return value === 'deny' || (object(value) && value['*'] === 'deny' + && Object.values(value).every(rule => rule === 'deny')); +} + +function isolatedOpenCode(config, agentName) { + if (!object(config) || !object(config.mcp) || !denyOnly(config.permission)) return false; + const agent = config.agent?.[agentName]; + return Object.values(config.mcp).every(server => object(server) && server.enabled === false) + && object(agent) && denyOnly(agent.permission) && agent.steps === 1 + && agent.mode === 'primary' && config.share === 'disabled' && config.autoupdate === false; +} + +async function opencodeIsolation(invoke, nonce, env) { + // --pure prevents external plugin loading. Config inspection itself may + // write ordinary host caches; the explicit connection action discloses this. + const configResult = await invoke('opencode', ['debug', 'config', '--pure'], { + env: { ...env, OPENCODE_DISABLE_AUTOUPDATE: 'true' }, + }); + const config = parse(configResult.stdout); + if (configResult.code !== 0 || !object(config) || (config.mcp !== undefined && !object(config.mcp))) return null; + const mcp = {}; + for (const key of Object.keys(config.mcp || {})) { + if (!safeId(key)) return null; + Object.defineProperty(mcp, key, { value: { enabled: false }, enumerable: true }); + } + const inherited = env.OPENCODE_CONFIG_CONTENT ? parseLocalJsonc(env.OPENCODE_CONFIG_CONTENT) : {}; + if (!object(inherited)) return null; + const agentName = `ak-health-${nonce}`; + const override = { ...inherited, mcp: { ...(inherited.mcp || {}), ...mcp }, + share: 'disabled', autoupdate: false, permission: 'deny', + agent: { ...(inherited.agent || {}), [agentName]: { description: 'Bounded provider health check', mode: 'primary', permission: 'deny', steps: 1 } } }; + const isolatedEnv = { ...env, OPENCODE_DISABLE_AUTOUPDATE: 'true', OPENCODE_CONFIG_CONTENT: JSON.stringify(override) }; + // Managed/organization settings load after inline overrides. Verify the + // effective result, rather than treating our requested restriction as proof. + const verified = await invoke('opencode', ['debug', 'config', '--pure'], { env: isolatedEnv }); + const effective = parse(verified.stdout); + if (verified.code !== 0 || !isolatedOpenCode(effective, agentName)) return null; + return { agentName, env: isolatedEnv }; + +} + +function nativeArguments(host, model, isolation) { + const modelArgs = model ? ['--model', model] : []; + if (host === 'claude') return ['--safe-mode', '--print', '--output-format', 'json', + '--tools', '', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', + '--disable-slash-commands', '--no-session-persistence', '--permission-mode', 'dontAsk', + ...modelArgs, '--settings', '{"disableAllHooks":true}']; + if (host === 'codex') return ['exec', '--json', '--ephemeral', '--sandbox', 'read-only', + ...isolation, '-c', 'approval_policy="never"', '-c', 'web_search="disabled"', + ...modelArgs, '-']; + return ['run', '--pure', '--format', 'json', '--agent', isolation.agentName, ...modelArgs]; +} + +function completed(host, stdout, challenge) { + const document = parse(stdout.trim()); + const events = object(document) ? [document] : stdout.trim().split('\n').filter(Boolean).map(parse); + if (!events.length || events.some(event => !object(event))) return 'unknown'; + if (events.some(event => event.type === 'error' || event.type === 'turn.failed' + || (event.type === 'result' && event.is_error === true))) return 'fail'; + if (host === 'claude') return events.some(event => event.type === 'result' + && event.subtype === 'success' && event.is_error === false + && event.result?.trim() === challenge) ? 'pass' : 'unknown'; + if (host === 'codex') { + if (events.some(event => ['mcp_tool_call', 'command_execution', 'file_change', 'web_search'].includes(event.item?.type))) return 'unknown'; + return events.some(event => event.type === 'turn.completed') + && events.some(event => event.type === 'item.completed' && event.item?.type === 'agent_message' + && event.item.text?.trim() === challenge) ? 'pass' : 'unknown'; + } + if (events.some(event => event.type === 'tool_use')) return 'unknown'; + return events.some(event => event.type === 'step_finish' && event.part?.reason === 'stop') + && events.some(event => event.type === 'text' && event.part?.text?.trim() === challenge) ? 'pass' : 'unknown'; +} + +/** A provider-only check with explicit consent. No raw stdout/stderr escapes. + * The caller binds this receipt to its own current project/config fingerprint. + * Native caches/session stores may change; normal managed policy still applies. + * @param {{host:string,cwd:string,model?:string,confirm?:boolean,timeoutMs?:number,signal?:AbortSignal, + * env?:NodeJS.ProcessEnv,run?:Function}} options + */ +export async function checkHostConnection({ host, cwd, model, confirm = false, + timeoutMs = 60_000, env = process.env, signal, run = runNative }) { + if (!HOSTS.has(host) || typeof cwd !== 'string' || !path.isAbsolute(cwd) + || (model !== undefined && !safeModel(model)) || !Number.isInteger(timeoutMs) + || timeoutMs < 1 || timeoutMs > 60_000) throw new TypeError('Invalid host connection check options'); + const result = (state, reason) => receipt(state, reason, model); + if (confirm !== true) return result('unknown', 'Explicit confirmation is required before a connection check that may incur inference costs.'); + const deadline = Date.now() + timeoutMs; + const invoke = async (command, args, overrides = {}) => { + const remaining = deadline - Date.now(); + if (signal?.aborted) return { code: 1, stdout: '', stderr: 'aborted' }; + if (remaining <= 0) return { code: 1, stdout: '', stderr: 'timed out' }; + return run(command, args, { cwd, env, signal, timeout: remaining, maxBuffer: MAX_BUFFER, input: '', ...overrides }); + }; + try { + const helpArgs = host === 'claude' ? ['--help'] : [host === 'codex' ? 'exec' : 'run', '--help']; + const help = await invoke(host, helpArgs); + if (help.code !== 0 || !flagsPresent(`${help.stdout}\n${help.stderr}`, REQUIRED_FLAGS[host])) { + return result('unknown', 'This host does not expose the supported bounded connection-check capabilities.'); + } + const nonce = randomBytes(12).toString('hex'); + /** @type {any} */ + let isolation = []; + if (host === 'codex') isolation = await codexIsolation(invoke); + if (host === 'opencode') isolation = await opencodeIsolation(invoke, nonce, env); + if (isolation === null) return result('unknown', 'Unable to establish tool and integration isolation for the connection check.'); + const challenge = `AK_HEALTH_${nonce}`; + const input = `Connection health check. Do not use tools or inspect files. Respond with exactly this single token and nothing else: ${challenge}`; + const response = await invoke(host, nativeArguments(host, model, isolation), { + input, ...(host === 'opencode' ? { env: isolation.env } : {}), + }); + if (response.code !== 0) { + if (/timed out|timeout|abort|maxBuffer|ENOENT|spawn unavailable|No safe Windows invocation/i.test(response.stderr || '')) { + return result('unknown', /timed out|timeout/i.test(response.stderr) ? 'Connection check timed out.' : 'Connection check could not complete within its execution limits.'); + } + return result('fail', 'The native host reported an unsuccessful connection check. Run its native diagnostics for details.'); + } + const state = completed(host, response.stdout, challenge); + return result(state, state === 'pass' ? 'The provider completed the bounded health challenge.' + : state === 'fail' ? 'The native host reported an unsuccessful provider request.' + : 'No supported, completed health response was observed; connection remains unverified.'); + } catch { + return result('unknown', 'The connection check could not be completed.'); + } +} diff --git a/src/lib/host-health-evidence.mjs b/src/lib/host-health-evidence.mjs new file mode 100644 index 0000000..600b17e --- /dev/null +++ b/src/lib/host-health-evidence.mjs @@ -0,0 +1,47 @@ +// Cache invalidation for observed local configuration, not a release attestation. +// HMAC prevents the public evidence key becoming a credential-guessing oracle. +import fs from 'node:fs'; +import path from 'node:path'; +import { createHmac, randomBytes } from 'node:crypto'; +import { hostHealthInputPaths } from './paths.mjs'; + +export function createHostHealthSnapshot({ secret = randomBytes(32), env = process.env, inputPaths = hostHealthInputPaths } = {}) { + return ({ cwd, cfg }) => { + const hash = createHmac('sha256', secret).update(JSON.stringify([cwd, cfg, env])); + let complete = true; + for (const file of inputPaths(cwd, env)) { + hash.update(file); + try { + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size > 2 * 1024 * 1024) { complete = false; hash.update('unassessed'); continue; } + hash.update(fs.realpathSync(file)); + const bytes = fs.readFileSync(file); + // Claude's login/config registry also contains frequently changing + // usage bookkeeping. Only its integration/trust fields govern health. + if (path.basename(file) === '.claude.json') { + const doc = JSON.parse(bytes.toString('utf8')); + const projects = Object.fromEntries(Object.entries(doc.projects ?? {}).map(([root, value]) => + [root, { mcpServers: value.mcpServers, allowedTools: value.allowedTools, hasTrustDialogAccepted: value.hasTrustDialogAccepted }])); + hash.update(JSON.stringify({ mcpServers: doc.mcpServers, projects })); + } else hash.update(bytes); + } catch (error) { + if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') complete = false; + hash.update(String(error.code)); + } + } + // Track the selected launchers too, including symlink targets. The native + // version observation is separately bound into each host's evidence. + for (const host of ['claude', 'codex', 'opencode']) { + for (const dir of (env.PATH ?? '').split(path.delimiter).slice(0, 256)) { + const file = path.resolve(dir, host + (process.platform === 'win32' ? '.cmd' : '')); + try { + const st = fs.statSync(file); + if (!st.isFile()) continue; + hash.update(JSON.stringify([host, fs.realpathSync(file), st.size, st.mtimeMs, st.ino])); + break; + } catch { /* next PATH entry */ } + } + } + return { key: hash.digest('hex'), complete }; + }; +} diff --git a/src/lib/host-readiness-local.mjs b/src/lib/host-readiness-local.mjs new file mode 100644 index 0000000..ebd19ea --- /dev/null +++ b/src/lib/host-readiness-local.mjs @@ -0,0 +1,338 @@ +// Read-only local selection assessment. Never call OpenCode debug config: +// upstream Config.get installs dependencies and may fetch organization config. +// Grounding: anomalyco/opencode v1.18.31 config/{config,paths,managed}.ts and +// provider/provider.ts defaultModel; Claude model-config/settings docs; Codex +// config-basic precedence. Unknown runtime/plugin/policy overrides stay scoped. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { readContextConfig } from './codex-context-config.mjs'; +import { withDb } from './sqlite.mjs'; + +const LIMIT = 1024 * 1024; +const plain = x => x !== null && typeof x === 'object' && !Array.isArray(x); +const obs = (state, reason) => ({ state, reason }); +const token = x => typeof x === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/[\]-]{0,255}$/.test(x) ? x : null; +const initial = () => ({ configuration: obs('unknown', 'Local configuration not assessed.'), + authentication: obs('unknown', 'Applicable credentials have not been established.'), + model: obs('unknown', 'Model selection has not been assessed.') }); + +function read(file, evidence, cap = LIMIT) { + try { + const fd = fs.openSync(file, 'r'); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size > cap) throw new Error('unsupported'); + const bytes = fs.readFileSync(fd); + if (bytes.length > cap) throw new Error('unsupported'); + const text = bytes.toString('utf8'); + if (!bytes.equals(Buffer.from(text))) throw new Error('unsupported'); + evidence.push(text); + return text; + } finally { fs.closeSync(fd); } + } catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} + +// Strings are copied verbatim: comment markers and commas inside URLs/secrets +// never participate in comment/trailing-comma removal. +export function parseLocalJsonc(source) { + let out = '', index = 0; + while (index < source.length) { + if (source[index] === '"') { + const start = index++; + let closed = false; + while (index < source.length) { + if (source[index] === '\\') { index += 2; continue; } + if (source[index++] === '"') { closed = true; break; } + } + if (!closed) throw new SyntaxError('invalid configuration'); + out += source.slice(start, index); continue; + } + if (source.slice(index, index + 2) === '//') { + while (index < source.length && source[index] !== '\n') index++; + out += '\n'; continue; + } + if (source.slice(index, index + 2) === '/*') { + const end = source.indexOf('*/', index + 2); + if (end < 0) throw new SyntaxError('invalid configuration'); + out += ' '; index = end + 2; continue; + } + out += source[index++]; + } + let clean = ''; + for (let i = 0; i < out.length; i++) { + if (out[i] === '"') { + clean += out[i++]; + for (; i < out.length; i++) { + clean += out[i]; + if (out[i] === '\\') { clean += out[++i]; continue; } + if (out[i] === '"') break; + } + } else if (out[i] !== ',' || !/^\s*[}\]]/.test(out.slice(i + 1))) clean += out[i]; + } + const value = JSON.parse(clean); + if (!plain(value)) throw new SyntaxError('invalid configuration'); + return value; +} + +function merge(a, b) { + const out = { ...a }; + for (const [key, value] of Object.entries(b)) { + if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('unsupported'); + out[key] = plain(value) && plain(out[key]) ? merge(out[key], value) : value; + } + return out; +} + +function ancestors(cwd) { + const dirs = []; + for (let dir = cwd; dirs.length < 64; dir = path.dirname(dir)) { + dirs.push(dir); + if (fs.existsSync(path.join(dir, '.git')) || path.dirname(dir) === dir) return dirs; + } + throw new Error('unsupported'); +} + +function expand(text, env) { + if (/\{file:/.test(text)) throw new Error('unsupported'); + text = text.replace(/\{env:([^}]+)\}/g, (_all, name) => { + if (!Object.hasOwn(env, name)) throw new Error('unsupported'); + return JSON.stringify(String(env[name])).slice(1, -1); + }); + return text; +} + +function document(file, evidence, env, jsonc = false) { + const raw = read(file, evidence); + if (raw === null) return {}; + const text = expand(raw, env); + const value = jsonc ? parseLocalJsonc(text) : JSON.parse(text); + if (!plain(value)) throw new SyntaxError('invalid configuration'); + return value; +} + +function modelResult(result, model, provider) { + if (model != null && !token(model)) throw new SyntaxError('invalid model selector'); + if (provider != null && !token(provider)) throw new Error('unsupported'); + result.model = obs('pass', model ? 'Explicit local model selection is configured; model access is not tested.' + : 'Native default model selection; model access is checked only by a connection test.'); + result.target = { ...(provider ? { provider } : {}), ...(model ? { model } : { nativeDefault: true }) }; +} + +function claudeSelection(options, result, evidence) { + const { cwd, home, env } = options; + const root = env.CLAUDE_CONFIG_DIR || path.join(home, '.claude'); + let config = {}; + for (const file of [path.join(root, 'settings.json'), path.join(cwd, '.claude/settings.json'), path.join(cwd, '.claude/settings.local.json')]) { + config = merge(config, document(file, evidence, env)); + } + // Native doctor supplies settings validity; local model is only a projection. + const managed = process.platform === 'darwin' ? '/Library/Application Support/ClaudeCode/managed-settings.json' + : process.platform === 'win32' ? path.join(env.ProgramFiles || 'C:\\Program Files', 'ClaudeCode/managed-settings.json') + : '/etc/claude-code/managed-settings.json'; + const policy = document(managed, evidence, env); + const providers = { CLAUDE_CODE_USE_BEDROCK: 'bedrock', CLAUDE_CODE_USE_VERTEX: 'vertex', CLAUDE_CODE_USE_FOUNDRY: 'foundry' }; + const effectiveEnv = { ...config.env, ...env, ...policy.env }; + const model = policy.model ?? effectiveEnv.ANTHROPIC_MODEL ?? config.model; + const provider = Object.entries(providers).find(([key]) => effectiveEnv[key] === '1')?.[1]; + modelResult(result, model, provider); +} + +function codexSelection({ cwd, home, env, codexSystemConfig }, result, evidence) { + const root = env.CODEX_HOME || path.join(home, '.codex'); + const user = read(path.join(root, 'config.toml'), evidence) ?? ''; + const system = read(codexSystemConfig ?? (process.platform === 'win32' + ? path.join(env.ProgramData || 'C:\\ProgramData', 'OpenAI', 'Codex', 'config.toml') : '/etc/codex/config.toml'), evidence) ?? ''; + // Project trust decides whether project files load. Do not assert a winning + // model from an arbitrary merge when a project override exists. + for (const dir of ancestors(cwd)) { + const project = read(path.join(dir, '.codex/config.toml'), evidence); + if (project !== null) { + const projected = readContextConfig(project); + if (projected.model || projected.provider || /^\s*(?:profile|config_profile)\s*=/m.test(project)) throw new Error('unsupported'); + } + } + if ([user, system].some(text => /^\s*(?:profile|config_profile)\s*=/m.test(text))) throw new Error('unsupported'); + const inherited = readContextConfig(system), selected = readContextConfig(user); + const config = { model: selected.model ?? inherited.model, provider: selected.provider ?? inherited.provider }; + modelResult(result, config.model, config.provider); + if (['ollama', 'lmstudio'].includes(config.provider)) { + result.authentication = obs('pass', 'Native local provider has no configured login requirement; server authentication is not tested.'); + } +} + +const PROVIDER_ENV = { anthropic: ['ANTHROPIC_API_KEY'], openai: ['OPENAI_API_KEY'], google: ['GOOGLE_GENERATIVE_AI_API_KEY', 'GOOGLE_API_KEY'], + openrouter: ['OPENROUTER_API_KEY'], groq: ['GROQ_API_KEY'], mistral: ['MISTRAL_API_KEY'], xai: ['XAI_API_KEY'] }; +function credentialPresent(value) { + if (value?.type === 'api') return typeof value.key === 'string' && value.key.length > 0; + if (value?.type === 'oauth') return (typeof value.refresh === 'string' && value.refresh.length > 0) + || (typeof value.access === 'string' && value.access.length > 0 && Number(value.expires) > Date.now()); + return false; +} +function localProvider(provider) { + try { return ['localhost', '127.0.0.1', '[::1]'].includes(new URL(provider?.options?.baseURL).hostname); } + catch { return false; } +} +function openCodeRemote(data, evidence) { + const dbfile = path.join(data, 'opencode.db'); + if (!fs.existsSync(dbfile)) return false; + const check = withDb(dbfile, db => { + const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='account_state'").get(); + if (!exists) return false; + return !!db.prepare('SELECT active_org_id FROM account_state WHERE id = 1').get()?.active_org_id; + }); + if (!check.ok) throw new Error('unsupported'); + evidence.push(JSON.stringify({ activeOrganization: check.value })); + return check.value; +} +function selectedOpenCodeAgent(config, agentDirs) { + const selectedAgent = config.default_agent || 'build'; + if (typeof selectedAgent !== 'string' || !/^[A-Za-z0-9_-]+$/.test(selectedAgent)) throw new Error('unsupported'); + if (agentDirs.some(dir => ['agent', 'agents'].some(folder => fs.existsSync(path.join(dir, folder, selectedAgent + '.md'))))) throw new Error('unsupported'); + const agent = config.agent?.[selectedAgent]; + if (agent !== undefined && !plain(agent)) throw new SyntaxError('invalid agent'); + if (agent?.mode === 'subagent' || agent?.disable === true) throw new SyntaxError('invalid default agent'); + if (!['build', 'plan'].includes(selectedAgent) && agent === undefined) throw new Error('unsupported'); + if (config.agent?.[selectedAgent]?.model !== undefined) config = { ...config, model: config.agent[selectedAgent].model }; + return config; +} + +function loadOpenCode({ cwd, home, env }, evidence) { + const global = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode'); + const data = path.join(env.XDG_DATA_HOME || path.join(home, '.local/share'), 'opencode'); + const auth = document(path.join(data, 'auth.json'), evidence, env); + if (Object.values(auth).some(value => value?.type === 'wellknown') || openCodeRemote(data, evidence)) throw new Error('unsupported'); + if (fs.existsSync(path.join(global, 'config'))) throw new Error('unsupported'); // legacy TOML migration is native-owned + let config = {}; + const load = file => { config = merge(config, document(file, evidence, env, true)); }; + for (const name of ['config.json', 'opencode.json', 'opencode.jsonc']) load(path.join(global, name)); + if (env.OPENCODE_CONFIG) load(path.resolve(cwd, env.OPENCODE_CONFIG)); + const dirs = env.OPENCODE_DISABLE_PROJECT_CONFIG === 'true' || env.OPENCODE_DISABLE_PROJECT_CONFIG === '1' ? [] : ancestors(cwd); + for (const dir of [...dirs].reverse()) for (const name of ['opencode.json', 'opencode.jsonc']) load(path.join(dir, name)); + for (const dir of [...new Set([...dirs.map(dir => path.join(dir, '.opencode')), path.join(home, '.opencode'), env.OPENCODE_CONFIG_DIR].filter(Boolean))]) { + for (const name of ['opencode.json', 'opencode.jsonc']) load(path.join(dir, name)); + } + if (env.OPENCODE_CONFIG_CONTENT) config = merge(config, parseLocalJsonc(expand(env.OPENCODE_CONFIG_CONTENT, env))); + const managed = env.OPENCODE_TEST_MANAGED_CONFIG_DIR || (process.platform === 'darwin' ? '/Library/Application Support/opencode' + : process.platform === 'win32' ? path.join(env.ProgramData || 'C:\\ProgramData', 'opencode') : '/etc/opencode'); + for (const name of ['opencode.json', 'opencode.jsonc']) load(path.join(managed, name)); + if (process.platform === 'darwin' && [path.join('/Library/Managed Preferences', os.userInfo().username, 'ai.opencode.managed.plist'), + '/Library/Managed Preferences/ai.opencode.managed.plist'].some(file => fs.existsSync(file))) throw new Error('unsupported'); + config = selectedOpenCodeAgent(config, [global, ...dirs.map(dir => path.join(dir, '.opencode')), path.join(home, '.opencode'), env.OPENCODE_CONFIG_DIR].filter(Boolean)); + return { config, auth }; +} + +const nonempty = value => typeof value === 'string' && value.trim().length > 0; +const strings = value => Array.isArray(value) && value.every(nonempty); +function knownField(value, key, predicate) { + if (value[key] !== undefined && !predicate(value[key])) throw new SyntaxError('invalid operational field'); +} +function validUrl(value) { + try { return nonempty(value) && ['http:', 'https:'].includes(new URL(value).protocol); } + catch { return false; } +} +function validateMcp(entry) { + if (!plain(entry)) throw new SyntaxError('invalid MCP entry'); + knownField(entry, 'enabled', value => typeof value === 'boolean'); + if (entry.type === undefined && entry.enabled === false) return; + if (entry.type === 'local') { + if (!strings(entry.command) || entry.command.length === 0) throw new SyntaxError('invalid MCP command'); + knownField(entry, 'environment', value => plain(value) && Object.values(value).every(v => typeof v === 'string')); + } else if (entry.type === 'remote') { + if (!validUrl(entry.url)) throw new SyntaxError('invalid MCP URL'); + knownField(entry, 'headers', value => plain(value) && Object.values(value).every(v => typeof v === 'string')); + } else if (entry.type === undefined || typeof entry.type !== 'string') throw new SyntaxError('invalid MCP type'); + else throw new Error('unsupported MCP transport'); +} +function validateProvider(entry) { + if (!plain(entry)) throw new SyntaxError('invalid provider'); + knownField(entry, 'options', plain); + knownField(entry, 'env', strings); + knownField(entry, 'models', plain); + knownField(entry, 'npm', nonempty); + if (entry.options) { + knownField(entry.options, 'apiKey', nonempty); + knownField(entry.options, 'baseURL', validUrl); + } + for (const model of Object.values(entry.models ?? {})) if (!plain(model)) throw new SyntaxError('invalid model definition'); +} +function validateAgent(entry) { + if (!plain(entry)) throw new SyntaxError('invalid agent'); + knownField(entry, 'model', nonempty); + knownField(entry, 'mode', value => ['primary', 'subagent', 'all'].includes(value)); + knownField(entry, 'disable', value => typeof value === 'boolean'); +} + +function validateOpenCode(config) { + for (const entry of Object.values(config.mcp ?? {})) validateMcp(entry); + for (const entry of Object.values(config.provider ?? {})) validateProvider(entry); + for (const entry of Object.values(config.agent ?? {})) validateAgent(entry); + for (const key of ['provider', 'mcp', 'agent']) if (config[key] !== undefined && !plain(config[key])) throw new SyntaxError('invalid configuration'); + for (const key of ['enabled_providers', 'disabled_providers']) if (config[key] !== undefined + && (!Array.isArray(config[key]) || config[key].some(value => typeof value !== 'string'))) throw new SyntaxError('invalid configuration'); +} + +function explicitOpenCode(config) { + let provider, model; + if (config.model !== undefined) { + if (typeof config.model !== 'string' || !config.model.includes('/')) throw new SyntaxError('invalid model selector'); + [provider, ...model] = config.model.split('/'); model = model.join('/'); + if (!provider || !model) throw new SyntaxError('invalid model selector'); + + } + return { provider, model }; +} + +function defaultOpenCode({ config, auth, home, env, allowed, credentialed }, evidence) { + let provider; + // Native default tries recent available selections, then a configured + // provider. Do not borrow credentials from an unrelated provider. + + const recent = document(path.join(env.XDG_STATE_HOME || path.join(home, '.local/state'), 'opencode/model.json'), evidence, env).recent; + if (Array.isArray(recent) && recent.length) throw new Error('unsupported'); // availability requires native provider catalog + const configured = Object.keys(config.provider ?? {}).filter(allowed); + if (configured.length === 1) provider = configured[0]; + else if (configured.length > 1) throw new Error('unsupported'); + else { + const candidates = [...new Set([...Object.keys(auth), ...Object.keys(PROVIDER_ENV)])].filter(id => allowed(id) && credentialed(id)); + if (candidates.length === 1) provider = candidates[0]; + else if (candidates.length > 1) throw new Error('unsupported'); + } + return provider; +} + +function opencodeSelection(options, result, evidence) { + const { home, env } = options; + const { config, auth } = loadOpenCode(options, evidence); + validateOpenCode(config); + const allowed = id => !config.disabled_providers?.includes(id) && (!config.enabled_providers || config.enabled_providers.includes(id)); + const selected = explicitOpenCode(config); + let provider = selected.provider; + const model = selected.model; + if (provider && !allowed(provider)) { result.model = obs('fail', 'Selected model provider is disabled by local configuration.'); return; } + const credentialed = id => credentialPresent(auth[id]) || nonempty(config.provider?.[id]?.options?.apiKey) + || [...(PROVIDER_ENV[id] ?? []), ...(config.provider?.[id]?.env ?? [])].some(key => !!env[key]); + if (!provider) provider = defaultOpenCode({ config, auth, home, env, allowed, credentialed }, evidence); + result.configuration = obs('pass', 'Local configuration layers and selection fields are readable; runtime plugins and provider connections are not executed.'); + modelResult(result, model, model ? provider : undefined); + if (provider && credentialed(provider)) result.authentication = obs('pass', 'Applicable provider credentials are locally configured; validity is not tested.'); + else if (provider && localProvider(config.provider?.[provider])) result.authentication = obs('pass', 'Local endpoint has no configured credential requirement; server authentication is not tested.'); +} + +/** Read local selection without executing config code or contacting providers. + * @param {{host:string,cwd:string,home?:string,env?:NodeJS.ProcessEnv,codexSystemConfig?:string}} options */ +export function assessLocalSelection({ host, cwd, home = os.homedir(), env = process.env, codexSystemConfig }) { + const result = initial(), evidence = []; + try { + const options = { cwd, home, env, codexSystemConfig }; + if (host === 'opencode') opencodeSelection(options, result, evidence); + else if (host === 'claude') claudeSelection(options, result, evidence); + else if (host === 'codex') codexSelection(options, result, evidence); + result.evidenceKey = createHash('sha256').update(JSON.stringify(evidence)).digest('hex'); + } catch (error) { + return { ...initial(), configuration: obs(error instanceof SyntaxError ? 'fail' : 'unknown', + error instanceof SyntaxError ? 'Local configuration contains invalid syntax or selection fields.' : 'Additional configuration or provider selection requires native assessment.') }; + } + return result; +} diff --git a/src/lib/host-readiness-probes.mjs b/src/lib/host-readiness-probes.mjs new file mode 100644 index 0000000..b5123dc --- /dev/null +++ b/src/lib/host-readiness-probes.mjs @@ -0,0 +1,118 @@ +// Native setup observations, never inference requests or a runtime-health claim. +// Native capabilities and output shapes are inspected, not exact version locks. +// Unsupported provider/configuration layers remain unknown, not broken. +import path from 'node:path'; +import { run as nativeRun, have as nativeHave } from './exec.mjs'; +import { assessLocalSelection } from './host-readiness-local.mjs'; + +const HOSTS = new Set(['claude', 'codex', 'opencode']); +const MAX_OUTPUT = 1024 * 1024; +const observation = (state, reason) => ({ state, reason }); +const unknown = reason => observation('unknown', reason); +const initial = () => ({ + installation: unknown('Installation has not been assessed.'), + configuration: unknown('Effective configuration has not been assessed.'), + authentication: unknown('Authentication requirements have not been assessed.'), + model: unknown('Model selection has not been assessed.'), +}); + +function versionFrom(host, output) { + const patterns = { + claude: /^(\d+\.\d+\.\d+) \(Claude Code\)$/, + codex: /^codex-cli (\d+\.\d+\.\d+)$/, + opencode: /^(?:opencode )?(\d+\.\d+\.\d+)$/, + }; + return patterns[host].exec(output.trim())?.[1] ?? null; +} + +function jsonObject(output) { + try { + const result = JSON.parse(output); + return result && typeof result === 'object' && !Array.isArray(result) ? result : null; + } catch { return null; } +} + +async function capability(probe, args, flag = '') { + const output = await probe([...args, '--help']); + return output?.code === 0 && /Usage:/i.test(output.stdout) && (!flag || output.stdout.includes(flag)); +} + +async function codexSetup(probe, result) { + // Listing uses the native configuration loader without starting MCP servers. + // Doctor also probes provider networks and desktop runtime: too broad for a + // passive badge. Never export this listing, which may contain credentials. + const report = await capability(probe, ['mcp', 'list'], '--json') ? await probe(['mcp', 'list', '--json']) : null; + if (report?.code === 0) { + try { + const entries = JSON.parse(report.stdout); + if (Array.isArray(entries) && entries.every(entry => entry && typeof entry.name === 'string')) { + result.configuration = observation('pass', 'Codex loaded invocation configuration; MCP connections and active-session overrides are not checked.'); + } + } catch { /* unrecognized output stays unknown */ } + } + const login = await capability(probe, ['login', 'status']) ? await probe(['login', 'status']) : null; + // Documented exit zero means native credentials are present, not that a + // provider request succeeded. Nonzero may be appropriate for custom/local + // providers and must not be interpreted as a mandatory sign-in failure. + if (login?.code === 0 && (!result.target?.provider || result.target.provider === 'openai')) { + result.authentication = observation('pass', 'Codex reports configured authentication; inference was not tested.'); + } +} + +async function claudeSetup(probe, result) { + // Known doctor output shape: a clean native settings check is bounded + // evidence, not a claim about session trust or organization policy. + const doctor = await capability(probe, ['doctor']) ? await probe(['doctor']) : null; + if (doctor?.code === 0 && doctor.stdout.startsWith('Claude Code doctor\n') + && /^No installation issues found\.$/m.test(doctor.stdout) + && !/\b(?:error|errors|invalid|warning|warnings|failed|failure)\b/i.test(doctor.stdout)) { + result.configuration = observation('pass', 'Claude doctor reported clean installation and settings diagnostics; session trust and remote policy are not checked.'); + } + const report = await capability(probe, ['auth', 'status'], '--json') ? await probe(['auth', 'status', '--json']) : null; + const data = report && [0, 1].includes(report.code) ? jsonObject(report.stdout) : null; + if (report?.code === 0 && data?.loggedIn === true && typeof data.apiProvider === 'string' && data.apiProvider.length > 0) { + result.authentication = observation('pass', 'Claude reports configured authentication; inference was not tested.'); + } else if (data?.loggedIn === false && data.apiProvider === 'firstParty') { + result.authentication = observation('fail', 'Claude reports no configured first-party authentication.'); + } +} + +/** Collect bounded, noninteractive, sanitized native setup observations. + * Unsupported capabilities and ambiguous errors remain unknown. Missing + * executables, invalid local config and explicit required sign-out are failures. + * No credentials, paths from native output, or native diagnostic text escape. + * @param {{host:string,cwd:string,run?:typeof nativeRun,have?:typeof nativeHave, + * home?:string,env?:NodeJS.ProcessEnv,assessSelection?:typeof assessLocalSelection}} options + */ +export async function collectHostSetup({ host, cwd, run = nativeRun, have = nativeHave, home, env = process.env, assessSelection = assessLocalSelection }) { + if (!HOSTS.has(host)) throw new TypeError('unsupported host'); + if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) throw new TypeError('cwd must be absolute'); + const result = initial(); + let present; + try { present = await have(host, { timeout: 5000, maxBuffer: MAX_OUTPUT, cwd }); } + catch { return result; } + if (present === false) { + result.installation = observation('fail', 'The enabled host executable is not available on PATH.'); + return result; + } + if (present !== true) return result; + const probe = async args => { + try { + const value = await run(host, args, { cwd, env, timeout: 10000, maxBuffer: MAX_OUTPUT }); + if (!value || typeof value.stdout !== 'string' || typeof value.code !== 'number' + || Buffer.byteLength(value.stdout, 'utf8') > MAX_OUTPUT) return null; + return value; + } catch { return null; } + }; + const launched = await probe(['--version']); + if (launched?.code !== 0) return result; + const version = versionFrom(host, launched.stdout); + result.installation = { ...observation('pass', 'The host version command completed successfully.'), ...(version ? { version } : {}) }; + const local = assessSelection({ host, cwd, home, env }); + Object.assign(result, local); + const localConfiguration = local.configuration; + if (host === 'codex') await codexSetup(probe, result); + else if (host === 'claude') await claudeSetup(probe, result); + if (localConfiguration?.state === 'fail') result.configuration = localConfiguration; + return result; +} diff --git a/src/lib/host-readiness.mjs b/src/lib/host-readiness.mjs new file mode 100644 index 0000000..328c64f --- /dev/null +++ b/src/lib/host-readiness.mjs @@ -0,0 +1,152 @@ +// Scoped local health plus explicit, short-lived provider connection evidence. +import path from 'node:path'; +import { createHash, randomBytes } from 'node:crypto'; +import { loadKitConfig } from './config.mjs'; +import { inspectHostAlignment } from './host-alignment.mjs'; +import { collectHostSetup } from './host-readiness-probes.mjs'; +import { createHostHealthSnapshot } from './host-health-evidence.mjs'; + +const HOSTS = ['claude', 'codex', 'opencode']; +const LABELS = { ok: 'OK', attention: 'Attention', unknown: 'Unknown', disabled: 'Disabled', checking: 'Checking' }; +const unknown = () => ({ state: 'unknown', reason: 'Check unavailable' }); +const BLOCKERS = { + 'retired-codex-mcp': 'Retired Codex transport configured; review with ak host align', + 'misplaced-claude-companion': 'Claude companion enabled in Codex; review with ak host align', +}; +const idleConnection = () => ({ state: 'not-run', reason: 'Connection test has not been run.' }); +const fingerprint = value => createHash('sha256').update(JSON.stringify(value)).digest('hex'); +const refused = (message, status = 409) => Object.assign(new Error(message), { status }); +const nativeConnection = async options => (await import('./host-health-connected.mjs')).checkHostConnection(options); + +/** @param {any} input */ +export function summarizeHostReadiness({ host, enabled, setup = {}, findings = [], connection = idleConnection() }) { + const checks = Object.fromEntries(['installation', 'configuration', 'model', 'authentication'].map(key => [key, setup[key] ?? unknown()])); + const alignment = findings.filter(finding => finding.host === host); + const blocked = alignment.find(finding => BLOCKERS[finding.code]); + checks.integration = blocked + ? { state: 'fail', reason: BLOCKERS[blocked.code] } + : alignment.some(finding => finding.code === 'config-unassessed') + ? { state: 'unknown', reason: 'Transport configuration could not be assessed' } + : { state: 'pass', reason: 'No known blocking transport configuration conflict; optional tool connectivity is separate.' }; + const values = Object.values(checks); + const localStatus = values.some(check => check.state === 'fail') ? 'attention' + : values.every(check => check.state === 'pass') ? 'ok' : 'unknown'; + const connected = ['pass', 'fail', 'unknown', 'running'].includes(connection.state); + const status = !enabled ? 'disabled' : connection.state === 'running' ? 'checking' + : localStatus === 'attention' || connection.state === 'fail' ? 'attention' + : connected && connection.state === 'unknown' ? 'unknown' : localStatus; + return { host, status, label: LABELS[status], level: connected ? 'connected' : 'local', + localStatus, checks: enabled ? checks : {}, target: setup.target ?? null, + connection, canCheckConnection: enabled && localStatus !== 'attention' && checks.installation.state === 'pass' }; +} + +/** One dashboard, one project, one in-flight paid check. Native output is not + * persisted. Local cache invalidates on observed input changes; connected proof + * expires after 15 minutes and never survives changed inputs or a server restart. + * @param {any} [options] */ +export function createHostReadinessReader({ cwd = process.cwd(), cacheMs = 60_000, now = Date.now, + connectionMaxAgeMs = 15 * 60_000, loadConfig = loadKitConfig, + inspectAlignment = inspectHostAlignment, probe = collectHostSetup, + snapshot = createHostHealthSnapshot(), connectionProbe = nativeConnection, +} = {}) { + let cached = null, pending = null, pendingKey = null, active = null, closed = false; + const proofs = new Map(), tokens = new Map(); + const controller = new AbortController(); + function inputs() { + const cfg = loadConfig(); + const observed = snapshot({ cwd, cfg }); + return { cfg, ...observed, key: fingerprint([observed.key, cfg]) }; + } + function decorate(base) { + const hosts = {}; + for (const host of HOSTS) { + const entry = base.entries[host]; + const proof = proofs.get(host); + const same = proof?.key === entry.key; + const fresh = same && now() - proof.at < connectionMaxAgeMs; + const connection = active?.host === host ? { state: 'running', reason: 'Connection check is running.' } + : fresh ? proof.result : proof ? { state: same ? 'expired' : 'changed', reason: same + ? 'Previous connection check expired; local checks remain available.' : 'Configuration changed; run a new connection check.', checkedAt: proof.result.checkedAt } : idleConnection(); + hosts[host] = { ...summarizeHostReadiness({ ...entry, connection }), evidenceKey: tokens.get(host)?.key === entry.key ? tokens.get(host).token : undefined, + checkedAt: base.checkedAt, canCheckConnection: false }; + hosts[host].canCheckConnection = !closed && !active && base.complete + && entry.enabled && hosts[host].localStatus !== 'attention' + && hosts[host].checks.installation?.state === 'pass'; + hosts[host].connectionUnavailable = hosts[host].canCheckConnection ? null + : active ? 'Another connection check is running.' : !entry.enabled ? 'This host is disabled.' + : !base.complete ? 'Some local configuration inputs could not be bounded for a connection check.' + : 'Review the local setup checks before testing a connection.'; + } + return { checkedAt: base.checkedAt, scope: 'Dashboard launch directory', project: path.basename(cwd), hosts }; + } + async function collect(input) { + let findings; + try { findings = inspectAlignment({ projectRoots: [cwd] }).findings; } + catch { findings = HOSTS.map(host => ({ host, code: 'config-unassessed' })); } + const entries = {}; + await Promise.all(HOSTS.map(async host => { + const enabled = input.cfg.integrations?.hosts?.[host] === true; + let setup = {}; + if (enabled) { try { setup = await probe({ host, cwd }); } catch { /* sanitized unknown */ } } + const key = fingerprint([input.key, host, setup, findings.filter(f => f.host === host).map(f => f.code)]); + entries[host] = { host, enabled, setup, findings, key }; + })); + // Never attach a successful check to configuration changed mid-probe. + const after = inputs(); + if (after.key !== input.key) throw refused('Configuration changed during local checks'); + const at = now(); + const value = { entries, complete: input.complete, checkedAt: new Date(at).toISOString() }; + if (pendingKey === input.key) { + for (const host of HOSTS) { + const key = entries[host].key; + if (tokens.get(host)?.key !== key) tokens.set(host, { key, token: randomBytes(32).toString('hex') }); + } + cached = { at, key: input.key, value }; + } + return value; + } + async function read({ force = false } = {}) { + if (closed) return null; + let input; + try { input = inputs(); } catch { return null; } + if (!force && cached?.key === input.key && now() - cached.at < cacheMs) return decorate(cached.value); + if (pending && pendingKey === input.key) return decorate(await pending); + pendingKey = input.key; + const task = collect(input); + pending = task; + try { return decorate(await task); } finally { if (pending === task) { pending = null; pendingKey = null; } } + } + read.checkConnection = async ({ host, confirm, evidenceKey, signal } = /** @type {any} */ ({})) => { + if (!HOSTS.includes(host)) throw refused('Unknown host', 400); + if (confirm !== true) throw refused('Explicit connection-check confirmation required', 400); + if (closed || active) throw refused('A connection check is already running or the dashboard is closed'); + const current = await read({ force: true }); + // Re-check after awaiting local probes; two requests may have raced. + if (active) throw refused('A connection check is already running'); + const entry = current?.hosts?.[host]; + if (!entry?.canCheckConnection) throw refused('Host prerequisites are unavailable'); + if (!evidenceKey || entry.evidenceKey !== evidenceKey) throw refused('Health evidence is stale; refresh before checking'); + const key = tokens.get(host).key; + tokens.set(host, { key, token: randomBytes(32).toString('hex') }); // consume confirmation + active = { host }; + try { + let result; + const checkSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + try { result = await connectionProbe({ host, cwd, ...(entry.target?.model ? { model: host === 'opencode' && entry.target.provider ? entry.target.provider + '/' + entry.target.model : entry.target.model } : {}), confirm: true, signal: checkSignal }); } + catch { result = { state: 'unknown', reason: 'Connection check could not complete.' }; } + const refreshed = await read({ force: true }); + if (!refreshed || tokens.get(host)?.key !== key) throw refused('Configuration changed during the connection check'); + if (checkSignal.aborted) result = { state: 'unknown', reason: 'Connection check was cancelled.' }; + const state = ['pass', 'fail', 'unknown'].includes(result?.state) ? result.state : 'unknown'; + const checkedAt = new Date(now()).toISOString(); + proofs.set(host, { key, at: now(), result: { state, checkedAt, + reason: typeof result?.reason === 'string' ? result.reason : 'The connection check could not establish a result.', + model: result?.model ?? entry.target?.model ?? null, scope: 'provider-inference', + integrations: { state: 'not-checked', reason: 'This check tests provider inference. Optional MCP tool connections are not exercised.' }, + } }); + } finally { active = null; } + return read(); + }; + read.close = () => { closed = true; controller.abort(); }; + return read; +} diff --git a/src/lib/paths.mjs b/src/lib/paths.mjs index 7eb16dc..db5f045 100644 --- a/src/lib/paths.mjs +++ b/src/lib/paths.mjs @@ -192,3 +192,32 @@ export const rufloMarketplaceRoot = () => path.join(home, '.claude', 'plugins', 'marketplaces', 'ruflo'); export { isWindows, home }; + +/** Bounded local inputs whose changes invalidate dashboard host-health evidence. + * Includes native custom roots and ancestor project layers. Remote policy is + * time-bound observation, never claimed to be snapshotted by this local list. */ +export function hostHealthInputPaths(cwd, env = process.env) { + const claude = env.CLAUDE_CONFIG_DIR || claudeDir(); + const codex = env.CODEX_HOME || codexDir(); + const opencode = env.OPENCODE_CONFIG_DIR || opencodeDir(); + const files = [ + path.join(claude, 'settings.json'), path.join(claude, '.credentials.json'), claudeUserMcpPath(), + claudeManagedSettingsPath(), path.join(codex, 'config.toml'), path.join(codex, 'auth.json'), + path.join(codex, 'requirements.toml'), '/etc/codex/config.toml', '/etc/codex/requirements.toml', + ...(process.platform === 'win32' ? [path.join(env.ProgramData || 'C:\\ProgramData', 'OpenAI', 'Codex', 'config.toml')] : []), + path.join(opencode, 'config.json'), path.join(opencode, 'opencode.json'), path.join(opencode, 'opencode.jsonc'), + path.join(env.XDG_STATE_HOME || path.join(home, '.local', 'state'), 'opencode', 'model.json'), + path.join(env.XDG_DATA_HOME || path.join(home, '.local', 'share'), 'opencode', 'auth.json'), + env.OPENCODE_CONFIG, + ].filter(Boolean); + let root = path.resolve(cwd); + for (let depth = 0; depth < 64; depth++) { + for (const relative of ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json', + '.codex/config.toml', '.codex/hooks.json', 'opencode.json', 'opencode.jsonc', + '.opencode/opencode.json', '.opencode/opencode.jsonc']) files.push(path.join(root, relative)); + const parent = path.dirname(root); + if (parent === root) break; + root = parent; + } + return [...new Set(files.map(file => path.resolve(file)))]; +} diff --git a/tests/kit/host-health-connected.test.mjs b/tests/kit/host-health-connected.test.mjs new file mode 100644 index 0000000..3137338 --- /dev/null +++ b/tests/kit/host-health-connected.test.mjs @@ -0,0 +1,234 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { checkHostConnection } from '../../src/lib/host-health-connected.mjs'; + +const help = '--safe-mode --print --output-format --tools --strict-mcp-config --mcp-config --settings --no-session-persistence --disable-slash-commands --permission-mode --json --sandbox --ephemeral --disable --pure --agent --format'; +const features = ['plugins', 'remote_plugin', 'hooks', 'apps', 'shell_tool', 'unified_exec', 'multi_agent', 'skill_mcp_dependency_install', 'browser_use', 'computer_use', 'code_mode', 'code_mode_host', 'image_generation', 'workspace_dependencies'].map(x => `${x} stable true`).join('\n'); +function boundary(host, response, calls = []) { + return async (command, args, opts) => { + calls.push({ command, args, opts }); + if (args.includes('--help')) return { code: 0, stdout: help, stderr: '' }; + if (args.includes('features')) return { code: 0, stdout: args.includes('--disable') ? features.replaceAll('true', 'false') : features, stderr: '' }; + if (args.includes('mcp')) return { code: 0, stdout: JSON.stringify([{ name: 'my-server', enabled: !args.some(a => a.includes('enabled=false')) }]), stderr: '' }; + if (args.includes('debug')) return { code: 0, stdout: opts.env?.OPENCODE_CONFIG_CONTENT?.includes('ak-health-') ? opts.env.OPENCODE_CONFIG_CONTENT : JSON.stringify({ mcp: { local: { type: 'local', command: ['missing'] } } }), stderr: '' }; + const challenge = opts.input.match(/AK_HEALTH_[a-f0-9]+/)[0]; + const outputs = { + claude: [{ type: 'result', subtype: 'success', is_error: false, result: challenge }], + codex: [{ type: 'item.completed', item: { type: 'agent_message', text: challenge } }, { type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } }], + opencode: [{ type: 'text', part: { text: challenge } }, { type: 'step_finish', part: { reason: 'stop' } }], + }; + return response?.(outputs[host], challenge) ?? { code: 0, stdout: outputs[host].map(x => JSON.stringify(x)).join('\n'), stderr: '' }; + }; +} +const base = { cwd: process.cwd(), confirm: true }; +for (const host of ['claude', 'codex', 'opencode']) { + test(`${host} passes only on completed matching native response`, async () => { + const result = await checkHostConnection({ ...base, host, run: boundary(host) }); + assert.equal(result.state, 'pass'); + assert.equal(result.integrations.state, 'not-checked'); + }); + test(`${host} does not treat exit zero as completion`, async () => { + const result = await checkHostConnection({ ...base, host, run: boundary(host, () => ({ code: 0, stdout: '{}', stderr: '' })) }); + assert.equal(result.state, 'unknown'); + }); + test(`${host} suppresses native error text including secrets`, async () => { + const result = await checkHostConnection({ ...base, host, run: boundary(host, () => ({ code: 1, stdout: 'SECRET', stderr: 'SECRET' })) }); + assert.equal(result.state, 'fail'); + assert.equal(JSON.stringify(result).includes('SECRET'), false); + }); +} +test('requires affirmative consent before any native process', async () => { + let calls = 0; + const result = await checkHostConnection({ ...base, confirm: false, host: 'codex', run: async () => { calls++; } }); + assert.equal(result.state, 'unknown'); + assert.equal(calls, 0); +}); +test('timeout is unknown and not a broken installation', async () => { + const result = await checkHostConnection({ ...base, host: 'claude', run: boundary('claude', () => ({ code: 1, stdout: '', stderr: 'timed out after 60000ms' })) }); + assert.equal(result.state, 'unknown'); + assert.match(result.reason, /timed out/i); +}); +test('unsupported required flags never launch inference', async () => { + let calls = 0; + const result = await checkHostConnection({ ...base, host: 'claude', run: async () => { calls++; return { code: 0, stdout: '--print', stderr: '' }; } }); + assert.equal(result.state, 'unknown'); + assert.equal(calls, 1); +}); +test('Codex disables integrations before inference and uses read-only sandbox', async () => { + const calls = []; + await checkHostConnection({ ...base, host: 'codex', run: boundary('codex', undefined, calls) }); + const call = calls.at(-1); + assert.ok(call.args.includes('mcp_servers.my-server.enabled=false')); + assert.ok(call.args.includes('plugins')); + assert.ok(call.args.includes('read-only')); + assert.ok(call.args.includes('approval_policy="never"')); + assert.ok(call.opts.timeout <= 60000); + assert.ok(call.opts.maxBuffer <= 256 * 1024); +}); +test('OpenCode uses tool-denying agent and disables discovered MCP', async () => { + const calls = []; + await checkHostConnection({ ...base, host: 'opencode', run: boundary('opencode', undefined, calls) }); + const call = calls.at(-1); + const config = JSON.parse(call.opts.env.OPENCODE_CONFIG_CONTENT); + assert.equal(config.mcp.local.enabled, false); + assert.equal(Object.values(config.agent)[0].permission, 'deny'); + assert.ok(call.args.includes('--pure')); + assert.equal(config.share, 'disabled'); +}); +test('Codex tool activity cannot pass even with a matching final response', async () => { + const result = await checkHostConnection({ ...base, host: 'codex', run: boundary('codex', events => ({ code: 0, stdout: [...events, { type: 'item.completed', item: { type: 'mcp_tool_call' } }].map(x => JSON.stringify(x)).join('\n'), stderr: '' })) }); + assert.equal(result.state, 'unknown'); +}); +test('malformed options never start subprocesses', async () => { + await assert.rejects(checkHostConnection({ ...base, host: 'invalid' }), TypeError); + await assert.rejects(checkHostConnection({ ...base, host: 'codex', model: '--bad' }), TypeError); + await assert.rejects(checkHostConnection({ ...base, host: 'codex', cwd: 'relative' }), TypeError); +}); +test('Codex refuses inference when a safety feature remains enabled', async () => { + const run = boundary('codex'); + const result = await checkHostConnection({ ...base, host: 'codex', run: async (command, args, opts) => { + if (args.includes('features') && args.includes('--disable')) return { code: 0, stdout: features, stderr: '' }; + return run(command, args, opts); + } }); + assert.equal(result.state, 'unknown'); + assert.match(result.reason, /isolation/); +}); +test('Codex refuses inference when native MCP override verification fails', async () => { + const run = boundary('codex'); + const result = await checkHostConnection({ ...base, host: 'codex', run: async (command, args, opts) => { + if (args.some(arg => arg.includes('enabled=false'))) return { code: 0, stdout: '[{"name":"other","enabled":true}]', stderr: '' }; + return run(command, args, opts); + } }); + assert.equal(result.state, 'unknown'); +}); +test('abort before check does not launch any subprocess', async () => { + const controller = new AbortController(); controller.abort(); + let calls = 0; + const result = await checkHostConnection({ ...base, host: 'claude', signal: controller.signal, run: async () => { calls++; } }); + assert.equal(result.state, 'unknown'); + assert.equal(calls, 0); +}); +test('native calls receive cancellation signal', async () => { + const controller = new AbortController(); + const calls = []; + await checkHostConnection({ ...base, host: 'claude', signal: controller.signal, run: boundary('claude', undefined, calls) }); + assert.ok(calls.every(call => call.opts.signal === controller.signal)); +}); +test('Claude excludes startup customizations and all tools', async () => { + const calls = []; + await checkHostConnection({ ...base, host: 'claude', run: boundary('claude', undefined, calls) }); + const args = calls.at(-1).args; + assert.ok(args.includes('--safe-mode')); + assert.equal(args[args.indexOf('--tools') + 1], ''); + assert.ok(args.includes('{"mcpServers":{}}')); + assert.ok(args.includes('{"disableAllHooks":true}')); +}); +test('a completed answer for the wrong nonce never passes', async () => { + const result = await checkHostConnection({ ...base, host: 'claude', run: boundary('claude', () => ({ code: 0, stdout: JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: 'AK_HEALTH_old' }), stderr: '' })) }); + assert.equal(result.state, 'unknown'); +}); +test('Codex missing optional feature does not prevent a supported probe', async () => { + const run = boundary('codex'); + const result = await checkHostConnection({ ...base, host: 'codex', run: async (command, args, opts) => { + const output = await run(command, args, opts); + if (args.includes('features')) output.stdout = output.stdout.split('\n').filter(line => !line.startsWith('code_mode')).join('\n'); + return output; + } }); + assert.equal(result.state, 'pass'); +}); +test('Codex pinned unified_exec does not defeat read-only shell policy', async () => { + const run = boundary('codex'); + const result = await checkHostConnection({ ...base, host: 'codex', run: async (command, args, opts) => { + const output = await run(command, args, opts); + if (args.includes('features')) output.stdout = output.stdout.replace('unified_exec stable false', 'unified_exec stable true'); + return output; + } }); + assert.equal(result.state, 'pass'); +}); +test('Codex names unsupported by its dotted override parser do not start inference', async () => { + const run = boundary('codex'); + const result = await checkHostConnection({ ...base, host: 'codex', run: async (command, args, opts) => { + if (args.includes('mcp')) return { code: 0, stdout: '[{"name":"has.dot","enabled":true}]', stderr: '' }; + return run(command, args, opts); + } }); + assert.equal(result.state, 'unknown'); +}); +test('OpenCode accepts native help written to stderr', async () => { + const run = boundary('opencode'); + const result = await checkHostConnection({ ...base, host: 'opencode', run: async (command, args, opts) => { + if (args.includes('--help')) return { code: 0, stdout: '', stderr: help }; + return run(command, args, opts); + } }); + assert.equal(result.state, 'pass'); +}); + +test('OpenCode refuses managed overrides that weaken effective connection isolation', async () => { + for (const weaken of [ + config => { config.mcp.injected={enabled:true}; }, + config => { config.permission={'*':'deny',edit:'allow'}; }, + config => { Object.values(config.agent)[0].permission='allow'; }, + config => { Object.values(config.agent)[0].steps=10; }, + ]) { + let inference=0; + const run=boundary('opencode',()=>{inference++;return {code:0,stdout:'{}',stderr:''};}); + const result=await checkHostConnection({...base,host:'opencode',run:async(command,args,opts)=>{ + const output=await run(command,args,opts); + if(args.includes('debug')&&opts.env?.OPENCODE_CONFIG_CONTENT?.includes('ak-health-')){ + const config=JSON.parse(output.stdout);weaken(config);output.stdout=JSON.stringify(config); + } + return output; + }}); + assert.equal(result.state,'unknown');assert.equal(inference,0); + } +}); + +test('Claude documented extended-context model selectors retain their exact selection', async () => { + const calls=[]; + const result=await checkHostConnection({...base,host:'claude',model:'sonnet[1m]',run:boundary('claude',undefined,calls)}); + assert.equal(result.state,'pass'); + assert.equal(calls.at(-1).args[calls.at(-1).args.indexOf('--model')+1],'sonnet[1m]'); +}); + +test('OpenCode preserves valid inline JSONC while applying connection restrictions', async () => { + const result=await checkHostConnection({...base,host:'opencode',env:{OPENCODE_CONFIG_CONTENT:'{ /* supported */ "model":"provider/model", }'},run:boundary('opencode')}); + assert.equal(result.state,'pass'); +}); + +test('native runner delivers the challenge over stdin and bounds a timed-out process tree', { skip: process.platform === 'win32' }, async t => { + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-health-process-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, 'claude'); + fs.writeFileSync(file, `#!/usr/bin/env node +const fs=require('node:fs'); +if(process.argv.includes('--help')){console.log(${JSON.stringify(help)});process.exit(0);} +let input='';process.stdin.on('data',chunk=>input+=chunk);process.stdin.on('end',()=>{ + if(process.env.AK_HEALTH_TEST_HANG==='1'){ + const child=require('node:child_process').spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + fs.writeFileSync(process.env.AK_HEALTH_TEST_PID,String(child.pid));setInterval(()=>{},1000);return; + } + const result=input.match(/AK_HEALTH_[a-f0-9]+/)[0]; + console.log(JSON.stringify({type:'result',subtype:'success',is_error:false,result})); +}); +`, { mode: 0o700 }); + const env = { ...process.env, PATH: dir + path.delimiter + process.env.PATH }; + const success = await checkHostConnection({ ...base, cwd: dir, host: 'claude', env }); + assert.equal(success.state, 'pass'); + const pidFile = path.join(dir, 'child.pid'); + const failed = await checkHostConnection({ ...base, cwd: dir, host: 'claude', timeoutMs: 800, + env: { ...env, AK_HEALTH_TEST_HANG: '1', AK_HEALTH_TEST_PID: pidFile } }); + assert.equal(failed.state, 'unknown'); + assert.match(failed.reason, /timed out/); + const pid = Number(fs.readFileSync(pidFile, 'utf8')); + const alive = () => { try { process.kill(pid, 0); return true; } catch { return false; } }; + t.after(() => { if (alive()) process.kill(pid, 'SIGKILL'); }); + for (let i = 0; i < 100 && alive(); i++) await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(alive(), false, 'owned grandchild was reaped'); +}); + +test('Claude JSON result accepts valid pretty-printed native output', async () => { + const result = await checkHostConnection({ ...base, host: 'claude', run: boundary('claude', events => ({code:0,stdout:JSON.stringify(events[0],null,2),stderr:''})) }); + assert.equal(result.state, 'pass'); +}); diff --git a/tests/kit/host-health-evidence.test.mjs b/tests/kit/host-health-evidence.test.mjs new file mode 100644 index 0000000..a387c44 --- /dev/null +++ b/tests/kit/host-health-evidence.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHostHealthSnapshot } from '../../src/lib/host-health-evidence.mjs'; + +test('evidence changes with configuration, credentials, environment and scope without exposing them', t => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(),'ak-health-evidence-')); + t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); + const file=path.join(dir,'config.json'),env={PATH:'',API_KEY:'SECRET'}; + const snapshot=createHostHealthSnapshot({env,inputPaths:()=>[file]}); + const options={cwd:dir,cfg:{enabled:true}}; + const absent=snapshot(options); + fs.writeFileSync(file,'{"model":"first"}'); + const first=snapshot(options); + assert.notEqual(first.key,absent.key); + assert.deepEqual(first,snapshot(options)); + fs.writeFileSync(file,'{"model":"second"}'); + assert.notEqual(snapshot(options).key,first.key); + const before=snapshot(options);env.API_KEY='NEW_SECRET'; + assert.notEqual(snapshot(options).key,before.key); + assert.notEqual(snapshot({...options,cwd:path.join(dir,'other')}).key,snapshot(options).key); + assert.ok(!JSON.stringify(snapshot(options)).includes('SECRET')); + assert.match(snapshot(options).key,/^[a-f0-9]{64}$/); +}); + +test('unbounded or non-file sources prevent source-bound connected claims', t => { + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ak-health-bound-')); + t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); + const snapshot=createHostHealthSnapshot({env:{PATH:''},inputPaths:()=>[dir]}); + assert.equal(snapshot({cwd:dir,cfg:{}}).complete,false); + const file=path.join(dir,'large');fs.writeFileSync(file,'x'.repeat(2*1024*1024+1)); + const large=createHostHealthSnapshot({env:{PATH:''},inputPaths:()=>[file]}); + assert.equal(large({cwd:dir,cfg:{}}).complete,false); +}); + +test('Claude usage bookkeeping does not invalidate health but transport changes do', t => { + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'ak-health-claude-')); + t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); + const file=path.join(dir,'.claude.json'); + const snapshot=createHostHealthSnapshot({env:{PATH:''},inputPaths:()=>[file]}); + const write=count=>fs.writeFileSync(file,JSON.stringify({startupCount:count,mcpServers:{},projects:{[dir]:{lastCost:count}}})); + write(1);const original=snapshot({cwd:dir,cfg:{}}); + write(2);assert.deepEqual(snapshot({cwd:dir,cfg:{}}),original); + fs.writeFileSync(file,JSON.stringify({mcpServers:{new:{command:'tool'}},projects:{}})); + assert.notEqual(snapshot({cwd:dir,cfg:{}}).key,original.key); +}); diff --git a/tests/kit/host-readiness-local.test.mjs b/tests/kit/host-readiness-local.test.mjs new file mode 100644 index 0000000..6ee500b --- /dev/null +++ b/tests/kit/host-readiness-local.test.mjs @@ -0,0 +1,195 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { assessLocalSelection } from '../../src/lib/host-readiness-local.mjs'; + +function fixture(t) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-health-local-')); + const cwd = path.join(home, 'project'); + fs.mkdirSync(path.join(cwd, '.git'), { recursive: true }); + t.after(() => fs.rmSync(home, { recursive: true, force: true })); + const write = (name, value) => { + const file = path.join(home, name); fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, typeof value === 'string' ? value : JSON.stringify(value)); + }; + return { home, cwd, write, env: { OPENCODE_TEST_MANAGED_CONFIG_DIR: path.join(home, 'managed') } }; +} + +test('OpenCode JSONC and local overrides select a credentialed provider without startup', t => { + const f = fixture(t); + f.write('.config/opencode/opencode.jsonc', '{ // comment\n "model":"old/model", "provider":{}, }'); + f.write('project/opencode.json', { model: 'anthropic/claude-example' }); + f.write('.local/share/opencode/auth.json', { anthropic: { type: 'oauth', access: 'SECRET', refresh: 'SECRET', expires: Date.now() + 10000 } }); + const result = assessLocalSelection({ host: 'opencode', ...f }); + assert.equal(result.configuration.state, 'pass'); + assert.equal(result.authentication.state, 'pass'); + assert.equal(result.model.state, 'pass'); + assert.deepEqual(result.target, { provider: 'anthropic', model: 'claude-example' }); + assert.ok(!JSON.stringify(result).includes('SECRET')); +}); + +test('OpenCode native default is supported without inventing a model ID', t => { + const f = fixture(t); + f.write('.local/share/opencode/auth.json', { anthropic: { type: 'api', key: 'SECRET' } }); + const result = assessLocalSelection({ host: 'opencode', ...f }); + assert.equal(result.configuration.state, 'pass'); + assert.equal(result.model.state, 'pass'); + assert.equal(result.authentication.state, 'pass'); + assert.deepEqual(result.target, { nativeDefault: true }); +}); + +test('OpenCode custom root and inline content obey precedence', t => { + const f = fixture(t); + f.write('custom/opencode.jsonc', '{"model":"first/model",}'); + const env = { ...f.env, OPENCODE_CONFIG_DIR: path.join(f.home, 'custom'), OPENCODE_CONFIG_CONTENT: '{"model":"openai/gpt-example"}', OPENAI_API_KEY: 'SECRET' }; + const result = assessLocalSelection({ host: 'opencode', ...f, env }); + assert.deepEqual(result.target, { provider: 'openai', model: 'gpt-example' }); + assert.equal(result.authentication.state, 'pass'); +}); + +test('remote config cannot be claimed as locally resolved', t => { + const f = fixture(t); + f.write('.local/share/opencode/auth.json', { 'https://example.invalid': { type: 'wellknown', token: 'SECRET', key: 'TOKEN' } }); + const result = assessLocalSelection({ host: 'opencode', ...f }); + assert.equal(result.configuration.state, 'unknown'); + assert.equal(result.model.state, 'unknown'); +}); + +test('invalid JSONC is actionable but unsupported references are neutral', t => { + const f = fixture(t); + f.write('project/opencode.jsonc', '{"model":}'); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).configuration.state, 'fail'); + f.write('project/opencode.jsonc', '{"model":"{file:remote-selection}"}'); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).configuration.state, 'unknown'); +}); + +test('explicit selected provider does not borrow another providers credentials', t => { + const f = fixture(t); + f.write('project/opencode.json', { model: 'openai/gpt-example' }); + f.write('.local/share/opencode/auth.json', { anthropic: { type: 'api', key: 'SECRET' } }); + const result = assessLocalSelection({ host: 'opencode', ...f }); + assert.equal(result.authentication.state, 'unknown'); +}); + +test('Claude local selection uses environment precedence and labels defaults', t => { + const f = fixture(t); + f.write('.claude/settings.json', { model: 'sonnet' }); + f.write('project/.claude/settings.local.json', { model: 'opus' }); + assert.equal(assessLocalSelection({ host: 'claude', ...f, env: { ANTHROPIC_MODEL: 'claude-example' } }).target.model, 'claude-example'); +}); + +test('Codex explicit model projection never interprets instruction text', t => { + const f = fixture(t); + f.write('.codex/config.toml', 'model = "gpt-example"\nmodel_provider = "custom"\n[other]\nmodel = "not-selected"\n'); + const result = assessLocalSelection({ host: 'codex', ...f }); + assert.deepEqual(result.target, { provider: 'custom', model: 'gpt-example' }); +}); + +test('disabled providers never supply authentication for native default selection', t => { + const f = fixture(t); + f.write('project/opencode.json', { disabled_providers: ['anthropic'] }); + f.write('.local/share/opencode/auth.json', { anthropic: { type: 'api', key: 'SECRET' } }); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).authentication.state, 'unknown'); +}); + +test('local custom endpoint uses its configured credential requirement without network', t => { + const f = fixture(t); + f.write('project/opencode.json', { provider: { lmstudio: { npm: '@ai-sdk/openai-compatible', options: { baseURL: 'http://localhost:1234/v1' } } } }); + const result = assessLocalSelection({ host: 'opencode', ...f }); + assert.equal(result.authentication.state, 'pass'); + assert.match(result.authentication.reason, /server authentication is not tested/); + assert.deepEqual(result.target, { nativeDefault: true }); +}); + +test('Codex project files without selection overrides preserve user model projection', t => { + const f = fixture(t); + f.write('.codex/config.toml', 'model = "gpt-example"'); + f.write('project/.codex/config.toml', '[features]\nhooks = true'); + assert.equal(assessLocalSelection({ host: 'codex', ...f }).target.model, 'gpt-example'); + f.write('project/.codex/config.toml', 'model = "different"'); + assert.equal(assessLocalSelection({ host: 'codex', ...f }).model.state, 'unknown'); +}); + +test('JSONC string URLs and comment-looking secrets preserve their literal meaning', t => { + const f = fixture(t); + f.write('project/opencode.jsonc', '{"model":"custom/example", "provider":{"custom":{"options":{"apiKey":"SECRET///*",},},},}'); + const result = assessLocalSelection({ host: 'opencode', ...f }); + assert.equal(result.authentication.state, 'pass'); + assert.ok(!JSON.stringify(result).includes('SECRET')); +}); + +test('Codex native local provider does not require a ChatGPT login', t => { + const f = fixture(t); + f.write('.codex/config.toml', 'model_provider = "ollama"\nmodel = "local-model"'); + const result = assessLocalSelection({ host: 'codex', ...f }); + assert.equal(result.authentication.state, 'pass'); + assert.match(result.authentication.reason, /no configured login requirement/); +}); + +test('OpenCode configured default agent model takes precedence over global model', t => { + const f = fixture(t); + f.write('project/opencode.json', { model: 'openai/other', default_agent: 'review', agent: { review: { model: 'anthropic/selected' } } }); + f.write('.local/share/opencode/auth.json', { anthropic: { type: 'api', key: 'SECRET' } }); + assert.deepEqual(assessLocalSelection({ host: 'opencode', ...f }).target, { provider: 'anthropic', model: 'selected' }); +}); + +test('auto-loaded selected agent model is not guessed from global config', t => { + const f = fixture(t); + f.write('project/.opencode/agents/build.md', '---\nmodel: anthropic/other\n---\nAgent'); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).model.state, 'unknown'); +}); + +test('malformed operational OpenCode fields cannot pass local health', t => { + const f = fixture(t); + f.write('.local/share/opencode/auth.json', { anthropic: { type: 'api', key: 'SECRET' } }); + for (const extra of [ + { mcp: { broken: 42 } }, { mcp: { broken: { type: 'local', command: [] } } }, + { mcp: { broken: { type: 'remote', url: 42 } } }, + { mcp: { broken: { type: 'remote', url: 'https://example.test', enabled: 'yes' } } }, + { provider: { anthropic: { options: { apiKey: {} } } } }, + { provider: { anthropic: { env: 'ANTHROPIC_API_KEY' } } }, + { provider: { anthropic: { models: [] } } }, { agent: { build: 42 } }, + ]) { + f.write('project/opencode.json', { model: 'anthropic/example', ...extra }); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).configuration.state, 'fail', JSON.stringify(extra)); + } +}); + +test('unknown future MCP transport is neutral while supported entries pass', t => { + const f = fixture(t); + f.write('project/opencode.json', { model: 'anthropic/example', mcp: { tool: { type: 'future' } } }); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).configuration.state, 'unknown'); + f.write('project/opencode.json', { model: 'anthropic/example', mcp: { local: { type: 'local', command: ['tool', '--flag'] }, remote: { type: 'remote', url: 'https://example.test/mcp' }, disabled: { enabled: false } } }); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).configuration.state, 'pass'); +}); + +test('Claude settings environment model overrides lower settings model', t => { + const f = fixture(t); + f.write('.claude/settings.json', { model: 'old-model', env: { ANTHROPIC_MODEL: 'actual-model' } }); + assert.equal(assessLocalSelection({ host: 'claude', ...f }).target.model, 'actual-model'); + assert.equal(assessLocalSelection({ host: 'claude', ...f, env: { ANTHROPIC_MODEL: 'process-model' } }).target.model, 'process-model'); +}); + +test('OpenCode inline content expands environment references like file config', t => { + const f = fixture(t); + const env = { ...f.env, MODEL: 'anthropic/example', OPENCODE_CONFIG_CONTENT: '{"model":"{env:MODEL}"}' }; + assert.deepEqual(assessLocalSelection({ host: 'opencode', ...f, env }).target, { provider: 'anthropic', model: 'example' }); +}); + +test('Codex system model provider inherits per key with user overrides', t => { + const f = fixture(t); + f.write('system.toml', 'model = "system-model"\nmodel_provider = "custom"'); + f.write('.codex/config.toml', 'model = "user-model"'); + const result = assessLocalSelection({ host: 'codex', ...f, codexSystemConfig: path.join(f.home, 'system.toml') }); + assert.deepEqual(result.target, { provider: 'custom', model: 'user-model' }); +}); + +test('unavailable default agents are neutral and known subagents cannot be primary', t => { + const f = fixture(t); + f.write('project/opencode.json', { model: 'anthropic/example', default_agent: 'missing' }); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).configuration.state, 'unknown'); + f.write('project/opencode.json', { model: 'anthropic/example', default_agent: 'review', agent: { review: { mode: 'subagent' } } }); + assert.equal(assessLocalSelection({ host: 'opencode', ...f }).configuration.state, 'fail'); +}); diff --git a/tests/kit/host-readiness-probes.test.mjs b/tests/kit/host-readiness-probes.test.mjs new file mode 100644 index 0000000..23c7862 --- /dev/null +++ b/tests/kit/host-readiness-probes.test.mjs @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { collectHostSetup } from '../../src/lib/host-readiness-probes.mjs'; +const cwd = '/tmp/readiness-project'; +const response = (stdout, code = 0) => ({ code, stdout, stderr: '' }); +const versions = { claude: '2.1.999 (Claude Code)', codex: 'codex-cli 0.999.0', opencode: '1.99.0' }; +const help = { 'doctor --help': 'Usage: claude doctor [options]\nCheck installation and settings', + 'auth status --help': 'Usage: claude auth status [options]\n--json', + 'mcp list --help': 'Usage: codex mcp list [OPTIONS]\n--json', + 'login status --help': 'Usage: codex login status [OPTIONS]' }; +const local = () => ({ configuration: { state: 'unknown', reason: 'native required' }, + authentication: { state: 'unknown', reason: 'native required' }, model: { state: 'pass', reason: 'Native default selection' }, target: { nativeDefault: true } }); +function probe(host, outputs = {}, present = true, selection = local()) { + const calls = []; + return { calls, collect: () => collectHostSetup({ host, cwd, have: async () => present, + assessSelection: () => selection, + run: async (bin, args, opts) => { + calls.push({ bin, args, opts }); const key = args.join(' '); + if (outputs[key] instanceof Error) throw outputs[key]; + return outputs[key] ?? response(key === '--version' ? versions[host] : help[key] ?? ''); + } }) }; +} + +test('missing executable is actionable and prevents diagnostics', async () => { + const p = probe('claude', {}, false); assert.equal((await p.collect()).installation.state, 'fail'); assert.deepEqual(p.calls, []); +}); +test('failed executable launch remains neutral', async () => { + const p = probe('codex', { '--version': response('timeout', 1) }); + assert.equal((await p.collect()).installation.state, 'unknown'); assert.equal(p.calls.length, 1); +}); +test('future versions use advertised native capabilities without version locks', async () => { + const result = await probe('codex', { 'mcp list --json': response('[]'), 'login status': response('Logged in using ChatGPT') }).collect(); + assert.equal(result.configuration.state, 'pass'); assert.equal(result.authentication.state, 'pass'); assert.equal(result.model.state, 'pass'); +}); +test('missing advertised JSON capability does not execute speculative commands', async () => { + const p = probe('codex', { 'mcp list --help': response('unknown') }); + const result = await p.collect(); assert.equal(result.configuration.state, 'unknown'); + assert.ok(!p.calls.some(call => call.args.join(' ') === 'mcp list --json')); +}); +test('Claude clean doctor and typed auth carry separate local evidence', async () => { + const result = await probe('claude', { doctor: response('Claude Code doctor\nNo installation issues found.'), + 'auth status --json': response(JSON.stringify({ loggedIn: true, apiProvider: 'firstParty', email: 'SECRET' })) }).collect(); + assert.equal(result.configuration.state, 'pass'); assert.equal(result.authentication.state, 'pass'); + assert.ok(!JSON.stringify(result).includes('SECRET')); +}); +test('explicit first party sign-out is actionable but custom auth failures are neutral', async () => { + for (const [provider, state] of [['firstParty', 'fail'], ['bedrock', 'unknown']]) { + const result = await probe('claude', { 'auth status --json': response(JSON.stringify({ loggedIn: false, apiProvider: provider }), 1) }).collect(); + assert.equal(result.authentication.state, state); + } +}); +test('Codex native login cannot attest a custom providers credentials', async () => { + const selection = { ...local(), target: { provider: 'custom', model: 'example' } }; + const result = await probe('codex', { 'mcp list --json': response('[]'), 'login status': response('Logged in') }, true, selection).collect(); + assert.equal(result.authentication.state, 'unknown'); +}); +test('malformed and thrown results never expose raw diagnostics', async () => { + for (const output of [response('SECRET not json'), new Error('SECRET')]) { + const result = await probe('codex', { 'mcp list --json': output }).collect(); + assert.equal(result.configuration.state, 'unknown'); assert.ok(!JSON.stringify(result).includes('SECRET')); + } +}); +test('OpenCode uses local assessed evidence without starting plugins, catalogs or inference', async () => { + const pass = { state: 'pass', reason: 'local evidence' }; + const p = probe('opencode', {}, true, { configuration: pass, authentication: pass, model: pass }); + const result = await p.collect(); assert.equal(result.configuration.state, 'pass'); assert.equal(result.authentication.state, 'pass'); + assert.deepEqual(p.calls.map(call => call.args), [['--version']]); +}); +test('all commands use bounded output, time and the requested cwd', async () => { + const p = probe('claude'); await p.collect(); + for (const call of p.calls) { assert.equal(call.opts.cwd, cwd); assert.ok(call.opts.timeout <= 10000); assert.ok(call.opts.maxBuffer <= 1048576); } +}); +test('unknown host and relative cwd cannot select arbitrary invocations', async () => { + await assert.rejects(collectHostSetup({ host: 'arbitrary', cwd }), /unsupported host/); + await assert.rejects(collectHostSetup({ host: 'claude', cwd: '../other' }), /cwd must be absolute/); +}); diff --git a/tests/kit/host-readiness.test.mjs b/tests/kit/host-readiness.test.mjs new file mode 100644 index 0000000..85ef51a --- /dev/null +++ b/tests/kit/host-readiness.test.mjs @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { summarizeHostReadiness, createHostReadinessReader } from '../../src/lib/host-readiness.mjs'; + +const pass = { state: 'pass', reason: 'Checked' }; +const setup = () => ({ installation: { ...pass, version: '1.2.3' }, configuration: pass, authentication: pass, model: pass }); + +test('configured requires positive installation, configuration and authentication evidence', () => { + assert.equal(summarizeHostReadiness({ host: 'codex', enabled: true, setup: setup() }).status, 'ok'); + for (const key of ['installation', 'configuration', 'authentication', 'model']) { + const partial = setup(); partial[key] = { state: 'unknown', reason: 'Not assessed' }; + assert.equal(summarizeHostReadiness({ host: 'codex', enabled: true, setup: partial }).status, 'unknown'); + } +}); + +test('usage diagnostics and historical responses cannot lower readiness or prove execution', () => { + const result = summarizeHostReadiness({ host: 'codex', enabled: true, setup: setup(), sourceHealth: { status: 'degraded' } }); + assert.equal(result.status, 'ok'); + assert.equal(result.connection.state, 'not-run'); +}); + +test('known blockers are actionable, unsupported configuration is unassessed', () => { + for (const code of ['retired-codex-mcp', 'misplaced-claude-companion']) { + assert.equal(summarizeHostReadiness({ host: 'codex', enabled: true, setup: setup(), findings: [{ host: 'codex', code }] }).status, 'attention'); + } + assert.equal(summarizeHostReadiness({ host: 'codex', enabled: true, setup: setup(), findings: [{ host: 'codex', code: 'config-unassessed' }] }).status, 'unknown'); + assert.equal(summarizeHostReadiness({ host: 'claude', enabled: true, setup: setup(), findings: [{ host: 'codex', code: 'retired-codex-mcp' }] }).status, 'ok'); +}); + +test('explicit setup failure outranks missing evidence; disabled hosts are neutral', () => { + assert.equal(summarizeHostReadiness({ host: 'claude', enabled: true, setup: { installation: { state: 'fail', reason: 'Install host' } } }).status, 'attention'); + assert.equal(summarizeHostReadiness({ host: 'claude', enabled: false, setup: setup() }).status, 'disabled'); +}); + +test('reader skips disabled hosts, isolates failures, and expires observations without preserving green', async () => { + let now = 1000, calls = 0, failed = false; + const read = createHostReadinessReader({ cwd: '/project', now: () => now, cacheMs: 100, + loadConfig: () => ({ integrations: { hosts: { claude: true, codex: true, opencode: false } } }), + inspectAlignment: () => ({ findings: [] }), + snapshot: () => ({ key: 'source', complete: true }), probe: async ({ host, cwd }) => { calls++; assert.equal(cwd, '/project'); if (failed && host === 'codex') throw Error('SECRET'); return setup(); }, + }); + const [first, same] = await Promise.all([read(), read()]); + assert.deepEqual(same, first); + assert.equal(calls, 2); + assert.equal(first.hosts.opencode.status, 'disabled'); + now += 101; failed = true; + const next = await read(); + assert.equal(next.hosts.codex.status, 'unknown'); + assert.equal(next.hosts.claude.status, 'ok'); + assert.ok(!JSON.stringify(next).includes('SECRET')); +}); + +test('configuration changes invalidate cache immediately', async () => { + let enabled = true, calls = 0; + const read = createHostReadinessReader({ cwd: '/project', + loadConfig: () => ({ integrations: { hosts: { codex: enabled } } }), + inspectAlignment: () => ({ findings: [] }), snapshot: () => ({ key: 'source', complete: true }), probe: async () => { calls++; return setup(); }, + }); + await read(); enabled = false; + assert.equal((await read()).hosts.codex.status, 'disabled'); + assert.equal(calls, 1); +}); + +test('connected checks require deliberate confirmation and the exact fresh local evidence', async () => { + let calls = 0; + const read = createHostReadinessReader({ cwd: '/project', + loadConfig: () => ({ integrations: { hosts: { codex: true } } }), + inspectAlignment: () => ({ findings: [] }), snapshot: () => ({ key: 'source', complete: true }), + probe: async () => setup(), connectionProbe: async () => { calls++; return { state: 'pass', reason: 'Provider responded' }; }, + }); + const initial = await read(); + assert.equal(calls, 0); + await assert.rejects(read.checkConnection({ host: 'codex', evidenceKey: initial.hosts.codex.evidenceKey }), /confirmation/); + await assert.rejects(read.checkConnection({ host: 'codex', confirm: true, evidenceKey: 'stale' }), /stale/); + const checked = await read.checkConnection({ host: 'codex', confirm: true, evidenceKey: initial.hosts.codex.evidenceKey }); + assert.equal(calls, 1); + assert.equal(checked.hosts.codex.status, 'ok'); + assert.equal(checked.hosts.codex.level, 'connected'); + assert.equal(checked.hosts.codex.connection.state, 'pass'); +}); + +test('source changes and expiration invalidate connection results without claiming the host broke', async () => { + let source = 'one', now = 1000; + const read = createHostReadinessReader({ cwd: '/project', now: () => now, connectionMaxAgeMs: 100, + loadConfig: () => ({ integrations: { hosts: { codex: true } } }), inspectAlignment: () => ({ findings: [] }), + snapshot: () => ({ key: source, complete: true }), probe: async () => setup(), + connectionProbe: async () => ({ state: 'pass', reason: 'Provider responded' }), + }); + const initial = await read(); + await read.checkConnection({ host: 'codex', confirm: true, evidenceKey: initial.hosts.codex.evidenceKey }); + now += 101; + const expired = await read(); + assert.equal(expired.hosts.codex.level, 'local'); + assert.equal(expired.hosts.codex.connection.state, 'expired'); + source = 'two'; + const changed = await read(); + assert.equal(changed.hosts.codex.level, 'local'); + assert.notEqual(changed.hosts.codex.evidenceKey, initial.hosts.codex.evidenceKey); +}); + +test('one connection request consumes its evidence and concurrent duplicate requests never spend twice', async () => { + let release, calls = 0; + const gate = new Promise(resolve => { release = resolve; }); + const read = createHostReadinessReader({ cwd: '/project', + loadConfig: () => ({ integrations: { hosts: { codex: true } } }), inspectAlignment: () => ({ findings: [] }), + snapshot: () => ({ key: 'source', complete: true }), probe: async () => setup(), + connectionProbe: async () => { calls++; await gate; return { state: 'pass', reason: 'Provider responded' }; }, + }); + const initial = await read(); + const request = { host: 'codex', confirm: true, evidenceKey: initial.hosts.codex.evidenceKey }; + const first = read.checkConnection(request); + await new Promise(resolve => setImmediate(resolve)); + await assert.rejects(read.checkConnection(request), /running|stale/); + release(); + await first; + await assert.rejects(read.checkConnection(request), /stale/); + assert.equal(calls, 1); +}); + +test('changes during a connection check discard its success and errors never leak native output', async () => { + let source = 'one'; + const read = createHostReadinessReader({ cwd: '/project', + loadConfig: () => ({ integrations: { hosts: { codex: true } } }), inspectAlignment: () => ({ findings: [] }), + snapshot: () => ({ key: source, complete: true }), probe: async () => setup(), + connectionProbe: async () => { source = 'two'; return { state: 'pass', reason: 'Provider responded' }; }, + }); + const initial = await read(); + await assert.rejects(read.checkConnection({ host: 'codex', confirm: true, evidenceKey: initial.hosts.codex.evidenceKey }), /changed/); + assert.equal((await read()).hosts.codex.level, 'local'); +}); From 3e8d46cf0eadd45bf4992f55d8b8296d90b755f3 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Sun, 20 Sep 2026 08:00:27 -0400 Subject: [PATCH 3/3] feat(dashboard): replace source warnings with qualified host health --- docs/DASHBOARD.md | 39 +++++ ...sed-operations-and-explicit-degradation.md | 7 +- ...st-setup-evidence-and-usage-diagnostics.md | 146 ++++++++++++++++++ docs/adr/README.md | 2 + package.json | 2 +- src/lib/dashboard-server.mjs | 28 +++- src/lib/dashboard/client.mjs | 4 +- src/lib/dashboard/client/boot.mjs | 2 + src/lib/dashboard/client/host-readiness.mjs | 96 ++++++++++++ src/lib/dashboard/client/intelligence.mjs | 2 + src/lib/dashboard/client/poll.mjs | 3 + src/lib/dashboard/client/usage.mjs | 7 +- src/lib/dashboard/host-health-api.mjs | 42 +++++ src/lib/dashboard/page.mjs | 23 ++- src/lib/dashboard/styles/usage.mjs | 38 ++++- tests/dashboard.test.cjs | 11 +- tests/kit/host-health-api.test.mjs | 45 ++++++ tests/ui/host-readiness.mjs | 83 ++++++++++ 18 files changed, 562 insertions(+), 18 deletions(-) create mode 100644 docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md create mode 100644 src/lib/dashboard/client/host-readiness.mjs create mode 100644 src/lib/dashboard/host-health-api.mjs create mode 100644 tests/kit/host-health-api.test.mjs create mode 100644 tests/ui/host-readiness.mjs diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 9cf1a70..7f934f5 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -852,3 +852,42 @@ Maintenance version measurements and update checks, model captures and local mod Relative ages remain relative. Published calendar dates (such as a retirement commitment) retain their calendar day; they are not midnight UTC instants. Stored/API timestamps and machine-readable `datetime` attributes retain their original instant. + +## Host health badges + +Claude, Codex and OpenCode use the same statuses: **OK**, **Attention**, +**Checking**, **Unknown**, and **Disabled**. Click a badge for the qualification, +check time, project and individual results. Keyboard users can focus the badge +and press Enter; Escape closes the details and restores focus. + +**Local OK** means the required local checks passed: executable launch, +supported configuration and provider/model selection, applicable authentication +setup, and known blocking integration configuration. These checks run in the +dashboard launch directory, independently of the Intelligence project picker. +Credentials are checked for setup, not remotely validated. Optional MCP tool +connections, runtime plugins and model access are not implied by Local OK. +Native defaults are valid selections; an explicit model setting is not required. +Unsupported or ambiguous evidence is Unknown, rather than an alarm. + +OpenCode has real local checks for its JSON/JSONC configuration layers, selected +provider, model/default agent, and applicable credentials or local endpoint. +Automatic checks do not invoke its config-debug command, which can install +dependencies. Unresolved remote configuration and native overrides stay Unknown. + +**Check local setup** refreshes the local evidence. **Check connection** requires +checking a confirmation box first: it sends one small provider request, using +normal billing and native context. Native startup may initialize dependencies +and update local cache/session files. Agent tools are restricted, and no repair +is requested. No inference runs during automatic polling. + +A connection result is qualified as **Connected**, scoped to provider inference. +It must contain a completed response to a fresh challenge; successful process +exit alone is insufficient. MCP tool connectivity is separately marked untested. +Only one connected check runs at a time. Results expire after 15 minutes, +invalidate when observed settings change, and are not persisted across dashboard +restarts. The local cache lasts at most one minute; a stale or failed observation +cannot silently provide a new positive result. + +**Usage → Usage data sources** retains transcript/database reading diagnostics. +Partial historical records do not lower host health. That scan includes the +selected period plus 90 days for comparisons. diff --git a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md index 5356072..04ff49e 100644 --- a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md +++ b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md @@ -1,7 +1,8 @@ # ADR-0023 — Fail-closed mutations and explicit degraded operation evidence - **Status:** Implemented -- **Updated:** 2026-08-26 — ADR-0035 applies fail-closed preflight, bounded evidence, and +- **Updated:** 2026-09-20 — ADR-0053 separates qualified local/connected host health from usage-source diagnostics +- **Earlier update:** 2026-08-26 — ADR-0035 applies fail-closed preflight, bounded evidence, and content-free degradation to the opt-in deja-vu companion - **Earlier update:** 2026-09-03 — ADR-0044 implements these fail-closed principles in the Maintenance coordinator while explicitly refusing to claim filesystem atomicity for native lifecycle, @@ -140,6 +141,10 @@ purge is planned and confirmed separately from wiring or package removal. ### 7. Usage source degradation is visible in the dashboard, for all four local sources +> **2026-09-20 amendment:** [ADR-0053](0053-host-setup-evidence-and-usage-diagnostics.md) +> supersedes the persistent-tabbar placement described below. Usage-source +> diagnostics remain available in Usage; persistent badges now assess scoped host health. + The Usage API's `sourceHealth` field is rendered as persistent local-source pills in the dashboard's sticky tabbar (right-aligned, visible on every view once Usage data has loaded once — not confined to the Usage panel). `ok`, `absent`, `degraded`, and `not-read` remain distinct, and diff --git a/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md b/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md new file mode 100644 index 0000000..8882c22 --- /dev/null +++ b/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md @@ -0,0 +1,146 @@ +# ADR-0053 — Qualified host health and separate usage diagnostics + +- **Status:** Implemented; not yet released +- **Date:** 2026-09-20 +- **Updated:** 2026-09-20 — replace setup-only badges with consistent local health and explicit provider connection checks for Claude, Codex and OpenCode +- **Amends:** [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md) +- **Related:** [ADR-0041](0041-host-neutral-hook-configuration-assurance.md), [ADR-0051](0051-supported-peer-delegation-and-host-realignment.md) + +## Context + +Usage acquisition once drove persistent branded host badges. One historical +Codex rollout could label the entire host degraded. The initial revision of this +ADR separated setup evidence, but left OpenCode unassessed and offered no actual +connection check. The user requested a qualified health check consistently for +all three hosts. This decision supersedes that setup-only contract. + +## Decision + +The persistent badge answers whether required checks passed at a stated level, +in the dashboard launch directory. Clicking it opens a keyboard-accessible dialog +with the level, project, timestamp, individual evidence and connection controls. +Usage date ranges and Intelligence project selection do not change this scope. + +| Status | Meaning | +| --- | --- | +| OK | Required checks at the displayed level passed | +| Attention | A check established a concrete actionable failure | +| Checking | A requested check is running | +| Unknown | Required evidence is unsupported, ambiguous, inaccessible or timed out | +| Disabled | Host is intentionally outside the enabled kit setup | + +### Local health + +Automatic local checks cover executable launch, supported configuration inputs, +provider/model selection, applicable authentication setup, and known blocking +transport configuration. Native defaults are valid; optional files and tools are +not mandatory. Credentials are locally configured evidence, never proof of remote +validity, quota, model access or provider uptime. Tool execution and plugin runtime +behavior remain outside this local claim. + +Native capabilities and recognizable result schemas gate probes, rather than +exact version equality. Unsupported contracts remain Unknown. Claude uses doctor +and structured authentication results plus local selection precedence. Codex uses +native configuration loading through MCP listing and login status, with supported +system/user selection projection; unresolved trust/profile overrides remain Unknown. +Its broader doctor invokes network/runtime checks and is not used automatically. + +OpenCode reads bounded local JSON/JSONC layers, environment substitutions, provider +filters, selected default-agent/model fields and applicable credentials. Known +invalid nested configuration is actionable; unresolved remote organization config, +file references, selected agent Markdown or ambiguous native-default selection is +Unknown. Native debug/config startup is not called automatically: its upstream +implementation can install dependencies, update files and fetch remote config. + +Each subprocess is bounded and emits only allowlisted states, reasons, versions +and model/provider selectors. Raw credentials and native diagnostic output never +enter the API. Local results have a 60-second single-flight cache. Observed file, +environment, executable and kit configuration changes invalidate cached evidence; +opaque keys use a per-server secret, not a public hash of credentials. Claude usage +bookkeeping does not invalidate otherwise unchanged integration configuration. + +### Explicit connected checks + +The dialog requires affirmative confirmation before sending a bounded inference +request. It discloses normal provider billing/context usage and native startup's +possible dependency, cache and session initialization. This is a user-triggered +native operation; polling never triggers inference or automatic repair. + +The connection adapter has one absolute native-execution budget of at most 60 +seconds, capped output, no retry, cancellation and process-tree cleanup. Local +revalidation occurs before and after the native check. A fresh nonce challenge +and a recognized successful completion are required; exit zero alone cannot pass. + +Claude uses supported safe mode with tools/hooks/MCP disabled. Codex uses a +read-only sandbox, denies approvals, disables supported tool/plugin features and +verifies that the effective MCP roster is disabled. OpenCode uses pure mode and +a dedicated deny-all agent with discovered MCP integrations disabled. Unsupported +isolation capabilities produce Unknown before inference. The intended explicit +model selection is preserved, including OpenCode's provider/model selector. + +Connected evidence is specifically **provider inference**. Optional MCP server +handshakes are not claimed; their untested state is shown separately. Local checks +must still pass for a Connected OK. This scope prevents provider success from +being presented as proof that every installed integration works. + +The server issues a source-bound confirmation token. A check requires that exact +current token, consumes it, refuses concurrent requests, and rejects changed +inputs before attaching a result. Connected evidence expires after 15 minutes, +invalidates on observed input changes, and exists only for this server session. +Changes during a check discard its result. Closing the dashboard or disconnecting +the requesting client cancels the owned connected subprocess. + +### HTTP and presentation boundaries + +`GET /api/host-health` reads local/cached evidence. The separate POST allowlist is +`/api/host-health/local` and `/api/host-health/connection`. Both require the session +token header and exact same-origin fetch metadata; query tokens cannot authorize +POST. Requests are size-bounded and accept fixed fields, never arbitrary commands, +paths, prompts, environment or client-selected models. Connection checks additionally +require explicit confirmation and a fresh observation token. Native errors are +sanitized before HTTP responses. + +Usage keeps its original four `sourceHealth` fields and full diagnostics under +Usage data sources. The historical scan's extra 90 days are disclosed. Those +observations never drive the host health badges. + +## Grounding + +- [Claude CLI and safe mode](https://code.claude.com/docs/en/cli-reference), [installation diagnostics](https://code.claude.com/docs/en/setup), and [model configuration](https://code.claude.com/docs/en/model-config). +- [Codex native commands](https://learn.chatgpt.com/docs/developer-commands?surface=cli) and [configuration precedence](https://learn.chatgpt.com/docs/config-file/config-basic). +- [OpenCode CLI](https://opencode.ai/docs/cli/), [JSON/JSONC configuration](https://opencode.ai/docs/config/), and [permissions](https://opencode.ai/docs/permissions/). +- Installed native help and bounded read-only preflight inspected on 2026-09-20. OpenCode v1.18.31 source explains configuration initialization and stdin/structured completion behavior. + +## Validation + +Tests cover native schema/capability changes, all three local adapters, defaults +and precedence, invalid nested configuration, credential isolation, source +invalidation, expiration, explicit consent, replay/concurrency rejection, origin +and token enforcement, and native challenge completion/cancellation. Browser +verification exercises all three hosts, keyboard navigation, consent, pending +state, desktop/mobile layouts and separation from usage diagnostics. + +No paid live inference is part of the test suite. Connected paths use deterministic +native-boundary fixtures; real read-only preflight checks stop before inference. + +### Implementation evidence — 2026-09-20 + +The final focused suites passed 77 tests, including real subprocess stdin and +process-tree timeout cleanup against a local fixture executable. No model +request was made by that fixture. The full browser suite passed 491 assertions +plus 9 tests; the final health dialog also passed independently on desktop and +mobile. Legacy suites, typecheck, lint, complexity checks, Markdown lint and +build passed (lint retains repository warnings). + +The broader unit run passed 4,159 tests with 6 existing skips and one failure: +the existing stock OpenCode fixture timed out installing its npm SDK dependency +before any provider request. It measured 92.05% line, 81.28% branch and 91.55% +function coverage. That broad run preceded the last effective-isolation and +HTTP error-classification regressions, which passed in the final focused suite. +The full repository test command is therefore not claimed green. + +A live local collection reported Local OK for Claude, Codex and OpenCode, with +connection state not-run for each. Claude/Codex real native preflight reached +intercepted inference; OpenCode native help was checked while config startup was +mocked to avoid initialization. No paid inference, commit, push or release was +performed. ADR-0051's alpha.48 publication correction remains in this branch. diff --git a/docs/adr/README.md b/docs/adr/README.md index b29dbd1..fef5e99 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -368,3 +368,5 @@ replayed parent history is excluded (with a boundary that survives the host writ as the history start), Claude sessions Codex imports are excluded and counted, counter restarts are summed and each delta books on its own day and model, oversized rollouts are read by a bounded-memory streaming reader, and rollouts that still cannot be parsed are reported. Cache schema 23. + +- [ADR-0053 — Qualified host health and separate usage diagnostics](0053-host-setup-evidence-and-usage-diagnostics.md) — scoped local and connected health; usage acquisition remains separate. diff --git a/package.json b/package.json index 1cf4dcb..f7ec2fd 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "scripts": { "test": "node --test --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 \"tests/kit/*.test.mjs\" && node tests/statusline-segments.test.cjs && node tests/statusline-window-ledger.test.cjs && node tests/statusline-brain.test.cjs && node tests/agentdb.test.cjs && node tests/health-history.test.cjs && node tests/harvest.test.cjs && node tests/dashboard.test.cjs && node tests/admin-model.test.cjs && node tests/admin.test.cjs", - "test:ui": "node tests/ui/dashboard-ui.mjs && node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/maintenance-host-alignment.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs", + "test:ui": "node tests/ui/dashboard-ui.mjs && node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/maintenance-host-alignment.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs tests/ui/host-readiness.mjs", "test:surface": "node --test tests/kit/dispatch-surface.test.mjs", "test:aqe-external-provider-live": "node --test tests/live/aqe-external-provider-transport.test.mjs", "test:qe-court-live": "node --test tests/live/qe-court-participant-transport.test.mjs", diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index e62f6a0..9a9816a 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -1,3 +1,5 @@ +import { HOST_HEALTH_POST_ROUTES, handleHostHealthPost } from './dashboard/host-health-api.mjs'; +import { createHostReadinessReader } from './host-readiness.mjs'; // dashboard-server.mjs — a read-only, localhost-only web dashboard for the kit. // // Zero runtime deps: a plain node:http server bound to 127.0.0.1. Routes: @@ -273,7 +275,8 @@ function censusBackedDiscovery() { } /** Assemble the full /api/status payload. */ -async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot }) { +async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot, getHostReadiness }) { + const readiness = getHostReadiness().catch(() => null); let status; try { status = await fetchStatus(); } catch (e) { status = { overall: 'unknown', rows: [], error: String(e && e.message || e) }; } const rows = Array.isArray(status?.rows) ? status.rows : []; @@ -318,6 +321,7 @@ async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot kit: { name: '@pacphi/agentic-kit', version: kitVersion() }, overall, error: status?.error ?? null, + hostReadiness: await readiness, rows, drift, // improvement — UNCHANGED contract: still the LAUNCHING project's own @@ -1038,7 +1042,7 @@ function lazyLive(liveOptions = {}) { * discoverProjects?: () => Array<{ path: string, label: string, source?: string }>, * machineWideIntel?: (projects: Array) => any, * models?: any, modelScopeKey?: string, system?: any, systemOptions?: any, - * maintenance?: any, maintenanceOptions?: any, + * maintenance?: any, maintenanceOptions?: any, hostReadiness?: any, * management?: any, managementOptions?: any }} [opts] * @returns {Promise<{ url: string, urlWithToken: string, port: number, token: string, close: () => Promise }>} */ @@ -1050,9 +1054,10 @@ export function startDashboard({ transcriptClientBuffer = 64, transcriptMaxClients = 16, intelWatch, intelClientBuffer = 256, intelMaxClients = 32, discoverProjects, machineWideIntel, models, modelScopeKey, system, systemOptions = {}, - maintenance, maintenanceOptions = {}, management, managementOptions = {}, + maintenance, maintenanceOptions = {}, management, managementOptions = {}, hostReadiness, } = {}) { const provide = fetchStatus || shellOutStatus(cwd); + const getHostReadiness = hostReadiness ?? (fetchStatus ? async () => null : createHostReadinessReader({ cwd })); const usageApi = usage || lazyUsage(); const getHooks = createHookDashboardReader({ hooks, cacheMs: hookCacheMs }); // Cache-only and lazy: model discovery is exclusively owned by @@ -1349,12 +1354,13 @@ export function startDashboard({ const qi = raw.indexOf('?'); const url = qi < 0 ? raw : raw.slice(0, qi); const query = new URLSearchParams(qi < 0 ? '' : raw.slice(qi + 1)); - // Maintenance has the only mutation allowlist (v1 compatibility routes plus + // Maintenance and explicit host connection checks have separate mutation allowlists (v1 compatibility routes plus // the exact ADR-0048 v2 POST paths). Every other route remains GET-only, // so adding a new read endpoint cannot accidentally create a write path. const maintenanceMutation = req.method === 'POST' && (MAINTENANCE_MUTATION_ROUTES.has(url) || MAINTENANCE_V2_MUTATION_ROUTES.has(url)); - if (req.method !== 'GET' && !maintenanceMutation) { + const healthMutation = req.method === 'POST' && HOST_HEALTH_POST_ROUTES.has(url); + if (req.method !== 'GET' && !maintenanceMutation && !healthMutation) { res.writeHead(405).end('method not allowed'); return; } @@ -1382,19 +1388,20 @@ export function startDashboard({ // Query tokens remain an SSE compatibility exception for GET. Mutation // capability can only be reached with the explicit header; it never rides // in a URL, browser history, referrer or server log. - const authorized = maintenanceMutation + const authorized = (maintenanceMutation || healthMutation) ? tokenMatches(req.headers['x-dash-token'], token) : checkToken(req, query); if (url.startsWith('/api/') && !authorized) { sendUnauthorized(res, 'Wrong or missing dashboard token.'); return; } - if (maintenanceMutation) { + if (maintenanceMutation || healthMutation) { const mutationRejection = maintenanceMutationRejection(req.headers); if (mutationRejection) { res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }); res.end(mutationRejection); return; } + if (healthMutation) { await handleHostHealthPost(url, req, res, getHostReadiness); return; } try { await (await getMaintenanceApi()).mutate(url, req, res); } catch { sendJson(res, 503, { error: 'maintenance operation unavailable' }); } return; @@ -1404,7 +1411,7 @@ export function startDashboard({ let payload; try { payload = await collectData({ - cwd, fetchStatus: provide, projectParam: query.get('project'), getProjectSnapshot, + cwd, fetchStatus: provide, projectParam: query.get('project'), getProjectSnapshot, getHostReadiness, }); } catch (e) { payload = { @@ -2029,6 +2036,10 @@ export function startDashboard({ // out separately into sse.mjs's sseRoute(). const ROUTES = { '/api/status': handleStatus, + '/api/host-health': async (_req, res) => { + try { sendJson(res, 200, await getHostReadiness()); } + catch { sendJson(res, 503, { error: 'Host health checks unavailable.' }); } + }, '/api/live': handleLiveSnapshot, '/api/live/history': handleLiveHistory, '/api/live/events': handleLiveEvents, @@ -2067,6 +2078,7 @@ export function startDashboard({ return listenLoopback(server, { port, token, close: async () => { + getHostReadiness.close?.(); shuttingDown = true; cancelLiveIdle(); for (const cleanup of [...liveClients]) cleanup(true); diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index dda5bb1..68cdfd9 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -91,6 +91,7 @@ let aboutSrc = readSplit('about.mjs'); aboutSrc = inject(aboutSrc, 'var ABOUT = []; // PLACEHOLDER:ABOUT_JS', `var ABOUT=${ABOUT_JS};`); const datetimeSrc = readSplit('datetime.mjs'); +const hostReadinessSrc = readSplit('host-readiness.mjs'); const intelligenceSrc = readSplit('intelligence.mjs'); const pollSrc = readSplit('poll.mjs'); // usage-rhythm.mjs declares its OWN `esc` on disk, and its comment says why: @@ -158,5 +159,6 @@ const bootSrc = readSplit('boot.mjs'); // sequence) running in the same relative order it always has. export const JS = ` (function(){ -${bootstrapSrc}${contextCard.toString()}${contextHostCard.toString()}${repositoryTree.toString()}${overviewSrc}${datetimeSrc}${intelligenceSrc}${pollSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); +${bootstrapSrc}${contextCard.toString()}${contextHostCard.toString()}${repositoryTree.toString()}${overviewSrc}${datetimeSrc}${hostReadinessSrc} +${intelligenceSrc}${pollSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); `; diff --git a/src/lib/dashboard/client/boot.mjs b/src/lib/dashboard/client/boot.mjs index f1ec275..a825cca 100644 --- a/src/lib/dashboard/client/boot.mjs +++ b/src/lib/dashboard/client/boot.mjs @@ -1,6 +1,7 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { wireHostHealth } from './host-readiness.mjs'; import { renderAbout, wireAboutNudge } from './about.mjs'; import { activeTab, initialLiveScope, setSystemView, setTab, syncHash, systemView } from './bootstrap.mjs'; import { tickClock, wireIntelPicker } from './intelligence.mjs'; @@ -19,6 +20,7 @@ import { loadUsage, setUsageView } from './usage.mjs'; // "state unknown" until the first /api/status response supplies the join. renderAbout(null); renderSystemFreshness(); + wireHostHealth(); wirePoll(); wireUsage(); wireIntelPicker(); diff --git a/src/lib/dashboard/client/host-readiness.mjs b/src/lib/dashboard/client/host-readiness.mjs new file mode 100644 index 0000000..b8fddcf --- /dev/null +++ b/src/lib/dashboard/client/host-readiness.mjs @@ -0,0 +1,96 @@ +// @ts-nocheck — browser bundle source; assembled by ../client.mjs. +import { esc, authHeaders } from './bootstrap.mjs'; +import { sourceHostIcon } from './usage.mjs'; + +var HEALTH_REPORT=null, HEALTH_HOST=null, HEALTH_BUSY=false, HEALTH_BUSY_HOST=null, HEALTH_ACK=null; +var HEALTH_NAMES={claude:'Claude Code',codex:'Codex',opencode:'OpenCode'}; +var HEALTH_LABELS={ok:'OK',attention:'Attention',unknown:'Unknown',disabled:'Disabled',checking:'Checking'}; +var HEALTH_CHECK_NAMES={installation:'Executable',configuration:'Configuration',model:'Provider / model selection',authentication:'Authentication setup',integration:'Integration configuration'}; + +function healthTime(value){var date=new Date(value);return Number.isFinite(date.getTime())?date.toLocaleString():'Not checked';} +function healthRow(){return HEALTH_REPORT&&HEALTH_REPORT.hosts&&HEALTH_REPORT.hosts[HEALTH_HOST];} +function healthState(state){return ({pass:'Passed',fail:'Needs attention',unknown:'Unknown','not-run':'Not run',running:'Checking',expired:'Expired',changed:'Settings changed','not-checked':'Not checked'})[state]||'Unknown';} + +export function renderHostReadiness(report,checking){ + var el=document.getElementById('host-readiness'); + if(!el)return; + if(report&&HEALTH_REPORT&&Date.parse(report.checkedAt)' + +''+sourceHostIcon(host)+'' + +''+HEALTH_LABELS[state]+''; + }).join(''); + renderHealthDialog(); +} + +function renderHealthDialog(){ + if(!HEALTH_HOST)return; + var row=healthRow(),dialog=document.getElementById('host-health-dialog'); + if(!dialog)return; + document.getElementById('host-health-title').textContent=HEALTH_NAMES[HEALTH_HOST]+' health'; + var level=row&&row.level==='connected'?'Connected':'Local'; + document.getElementById('host-health-summary').textContent=HEALTH_BUSY&&HEALTH_BUSY_HOST===HEALTH_HOST?'Checking…': + (row?HEALTH_LABELS[row.status]:'Unknown')+' · '+level+' checks · '+healthTime(row&&row.level==='connected'&&row.connection&&row.connection.checkedAt||row&&row.checkedAt); + document.getElementById('host-health-project').textContent='Project: '+(HEALTH_REPORT&&HEALTH_REPORT.project||'dashboard launch directory'); + var checks=row&&row.checks||{}; + document.getElementById('host-health-checks').innerHTML=Object.keys(HEALTH_CHECK_NAMES).map(function(key){ + var check=checks[key]||{state:'unknown',reason:row&&row.status==='disabled'?'Host is disabled.':'No current evidence.'}; + return '
  • '+HEALTH_CHECK_NAMES[key]+' '+healthState(check.state)+'' + +''+esc(check.reason)+(check.version?' · '+esc(check.version):'')+'
  • '; + }).join(''); + var target=row&&row.target; + document.getElementById('host-health-target').textContent='Selection: '+(target?[target.provider,target.model].filter(Boolean).join(' / ')||'native host default':'native host default or unassessed'); + var connection=row&&row.connection||{state:'not-run'}; + document.getElementById('host-health-connection').textContent=healthState(connection.state)+(connection.checkedAt?' · '+healthTime(connection.checkedAt):'')+(connection.reason?' — '+connection.reason:''); + document.getElementById('host-health-integrations').textContent='Connected check scope: provider inference. Optional MCP tool connections are not tested.'; + document.getElementById('host-health-eligibility').textContent=row&&row.connectionUnavailable||''; + var consent=document.getElementById('host-health-consent'); + if(!row||HEALTH_ACK!==row.evidenceKey){consent.checked=false;HEALTH_ACK=null;} + consent.disabled=HEALTH_BUSY||!row||!row.canCheckConnection; + document.getElementById('host-health-connect').disabled=HEALTH_BUSY||!row||!row.canCheckConnection||!consent.checked; + document.getElementById('host-health-refresh').disabled=HEALTH_BUSY; +} + +async function runHealthCheck(connected){ + var row=healthRow(); + if(HEALTH_BUSY||!HEALTH_HOST||connected&&(!row||!row.canCheckConnection||HEALTH_ACK!==row.evidenceKey))return; + var body={host:HEALTH_HOST}; + if(connected){body.confirm=true;body.evidenceKey=row.evidenceKey;} + HEALTH_BUSY=true;HEALTH_BUSY_HOST=HEALTH_HOST; + document.getElementById('host-health-message').textContent=connected?'Checking connection and revalidating local setup…':'Checking local setup…'; + renderHostReadiness(HEALTH_REPORT); + try{ + var response=await fetch('/api/host-health/'+(connected?'connection':'local'),{method:'POST',headers:Object.assign({'content-type':'application/json'},authHeaders()),body:JSON.stringify(body)}); + var data=await response.json(); + if(!response.ok)throw new Error(data.error||'Health check unavailable.'); + HEALTH_BUSY=false;HEALTH_BUSY_HOST=null;HEALTH_ACK=null; + renderHostReadiness(data); + document.getElementById('host-health-message').textContent='Check completed.'; + }catch(error){ + HEALTH_BUSY=false;HEALTH_BUSY_HOST=null;HEALTH_ACK=null; + renderHostReadiness(null); + document.getElementById('host-health-message').textContent=error.message||'Health check unavailable.'; + } +} + +export function wireHostHealth(){ + var region=document.getElementById('host-readiness'),dialog=document.getElementById('host-health-dialog'); + if(!region||!dialog)return; + region.addEventListener('click',function(event){ + var button=event.target.closest('[data-health-host]');if(!button)return; + HEALTH_HOST=button.getAttribute('data-health-host');HEALTH_ACK=null; + document.getElementById('host-health-message').textContent='';renderHealthDialog();dialog.showModal(); + }); + document.getElementById('host-health-close').addEventListener('click',function(){dialog.close();}); + dialog.addEventListener('close',function(){var button=region.querySelector('[data-health-host="'+HEALTH_HOST+'"]');if(button)button.focus();}); + document.getElementById('host-health-consent').addEventListener('change',function(event){HEALTH_ACK=event.target.checked&&healthRow()?healthRow().evidenceKey:null;renderHealthDialog();}); + document.getElementById('host-health-refresh').addEventListener('click',function(){runHealthCheck(false);}); + document.getElementById('host-health-connect').addEventListener('click',function(){runHealthCheck(true);}); + renderHostReadiness(null,true); +} diff --git a/src/lib/dashboard/client/intelligence.mjs b/src/lib/dashboard/client/intelligence.mjs index 518c39c..be2ef3d 100644 --- a/src/lib/dashboard/client/intelligence.mjs +++ b/src/lib/dashboard/client/intelligence.mjs @@ -1,6 +1,7 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { renderHostReadiness } from './host-readiness.mjs'; import { renderAbout } from './about.mjs'; import { DASH_TOKEN, activeTab, esc, overviewView, positionThumb } from './bootstrap.mjs'; import { renderModelSummary } from './model-lifecycle.mjs'; @@ -294,6 +295,7 @@ import { fmtNum, kpi } from './usage.mjs'; export function render(data){ if(!data)return; LAST=data; + renderHostReadiness(data.hostReadiness); renderVerdict(data.overall); renderNotice(data.drift); renderAbout(data); diff --git a/src/lib/dashboard/client/poll.mjs b/src/lib/dashboard/client/poll.mjs index 540865a..b4beb27 100644 --- a/src/lib/dashboard/client/poll.mjs +++ b/src/lib/dashboard/client/poll.mjs @@ -1,6 +1,7 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { renderHostReadiness } from './host-readiness.mjs'; import { renderAbout } from './about.mjs'; import { DASH_TOKEN_KEY, activeTab, authHeaders, hideGate, showGate, systemView } from './bootstrap.mjs'; import { render, tickClock } from './intelligence.mjs'; @@ -117,6 +118,8 @@ import { loadModelLifecycle, loadUsage } from './usage.mjs'; if(seq===intelRequestSeq)render(d); tickClock(); }).catch(function(){ + if(seq!==intelRequestSeq)return; + renderHostReadiness(null); var t=document.getElementById("verdict-text"); if(t)t.textContent="server unreachable"; // About is editorial content plus a runtime join. Losing the join must // cost the chips, never the page: every card still renders, each one diff --git a/src/lib/dashboard/client/usage.mjs b/src/lib/dashboard/client/usage.mjs index e3a2bff..ee4f7d3 100644 --- a/src/lib/dashboard/client/usage.mjs +++ b/src/lib/dashboard/client/usage.mjs @@ -89,7 +89,7 @@ import { renderUsage } from './usage-orchestrators.mjs'; var el=document.getElementById("u-source-health"); if(!el)return; health=health||{}; - var pills=[]; + var pills=[],details=[]; for(var g=0; g'+esc(detail)+''); pills.push('' +''+sourceHostIcon(grp.host)+'' - +''+esc(lead.status)+'' + +''+esc(({ok:'Readable',degraded:'Partial data',absent:'No records','not-read':'Not checked'})[lead.status]||'Not checked')+'' +''); } el.hidden=pills.length===0; - el.innerHTML=pills.join(""); + el.innerHTML=pills.join("")+'
      '+details.join("")+'
    '; } export function loadUsage(force){ diff --git a/src/lib/dashboard/host-health-api.mjs b/src/lib/dashboard/host-health-api.mjs new file mode 100644 index 0000000..5118e8b --- /dev/null +++ b/src/lib/dashboard/host-health-api.mjs @@ -0,0 +1,42 @@ +import { sendJson } from '../loopback-server.mjs'; +import { readMaintenanceJson } from './maintenance-security.mjs'; + +export const HOST_HEALTH_POST_ROUTES = new Set(['/api/host-health/local', '/api/host-health/connection']); +const HOSTS = ['claude', 'codex', 'opencode']; + +/** The server enforces token + same-origin before entering this handler. + * Only a fixed host and server-issued observation token are accepted; callers + * cannot supply a command, prompt, model, project path, timeout, or environment. */ +export async function handleHostHealthPost(url, req, res, read) { + try { + const body = await readMaintenanceJson(req, { maxBytes: 4096 }); + if (!body || Array.isArray(body) || typeof body !== 'object' + || Object.keys(body).some(key => !['host', 'confirm', 'evidenceKey'].includes(key)) + || !HOSTS.includes(body.host)) { + sendJson(res, 400, { error: 'Invalid host health request.' }); return; + } + if (url === '/api/host-health/local') { + sendJson(res, 200, await read({ force: true })); return; + } + if (body.confirm !== true || typeof body.evidenceKey !== 'string' || !/^[a-f0-9]{64}$/.test(body.evidenceKey)) { + sendJson(res, 400, { error: 'Confirm the connection check using fresh health evidence.' }); return; + } + if (typeof read.checkConnection !== 'function') { + sendJson(res, 503, { error: 'Connection checks unavailable.' }); return; + } + const controller = new AbortController(); + const abort = () => { if (!res.writableEnded) controller.abort(); }; + res.on('close', abort); + try { + const report = await read.checkConnection({ ...body, signal: controller.signal }); + sendJson(res, 200, report); + } finally { res.off('close', abort); } + } catch (error) { + const code = error.status ?? error.statusCode; + const status = [400, 409, 413, 415].includes(code) ? code : 503; + const message = status === 409 ? 'Health evidence changed or a check is already running. Refresh and try again.' + : status === 415 ? 'Health requests must use application/json.' + : status === 413 ? 'Health request is too large.' : status === 400 ? 'Invalid health request.' : 'Health check unavailable.'; + sendJson(res, status, { error: message }); + } +} diff --git a/src/lib/dashboard/page.mjs b/src/lib/dashboard/page.mjs index 8ed531c..e9d0e1f 100644 --- a/src/lib/dashboard/page.mjs +++ b/src/lib/dashboard/page.mjs @@ -142,9 +142,25 @@ export function renderPage({ name, version }) { - + + +

    Host health

    +

    +

    +
      +

      +

      Connection check

      +

      +

      +

      +

      Sends one short request using your selected host and provider. Normal provider billing and native context usage apply. Native startup may initialize dependencies and update local cache or session files. Agent tools are restricted; this check does not repair your setup.

      + +
      +

      +
      +