From 64e3ed834b57bba0194415c881c3902cce709fb2 Mon Sep 17 00:00:00 2001 From: sam2tom Date: Tue, 15 Sep 2026 23:20:41 +0800 Subject: [PATCH 1/4] feat: add read-only Agent Core doctor --- package.json | 7 +- scripts/core-doctor.mjs | 714 ++++++++++++++++++ scripts/core-doctor.test.mjs | 466 ++++++++++++ scripts/fixtures/core-doctor/agents-list.json | 7 + .../core-doctor/daemon-status-absent.txt | 4 + .../core-doctor/daemon-status-paired.txt | 7 + .../fixtures/core-doctor/keys-matched.json | 10 + .../fixtures/core-doctor/keys-mismatch.json | 10 + .../core-doctor/redaction-corpus.json | 11 + 9 files changed, 1233 insertions(+), 3 deletions(-) create mode 100644 scripts/core-doctor.mjs create mode 100644 scripts/core-doctor.test.mjs create mode 100644 scripts/fixtures/core-doctor/agents-list.json create mode 100644 scripts/fixtures/core-doctor/daemon-status-absent.txt create mode 100644 scripts/fixtures/core-doctor/daemon-status-paired.txt create mode 100644 scripts/fixtures/core-doctor/keys-matched.json create mode 100644 scripts/fixtures/core-doctor/keys-mismatch.json create mode 100644 scripts/fixtures/core-doctor/redaction-corpus.json diff --git a/package.json b/package.json index c6d6480..e38cba7 100644 --- a/package.json +++ b/package.json @@ -19,13 +19,14 @@ "dev": "pnpm --filter @agents-core-web/web dev", "build": "pnpm -r --filter \"@agents-core-web/*\" --if-present build", "typecheck": "pnpm -r --filter \"@agents-core-web/*\" --if-present typecheck", - "test:root": "node --test scripts/agent-issue-intake.test.mjs", + "test:root": "node --test scripts/agent-issue-intake.test.mjs scripts/core-doctor.test.mjs", "test": "pnpm test:root && pnpm -r --filter \"@agents-core-web/*\" --if-present test", "test:acceptance": "playwright test", - "check:root": "node --check scripts/init-agent-workspace.mjs && node --check scripts/agent-issue-intake.mjs && node --check scripts/agent-issue-intake.test.mjs", + "check:root": "node --check scripts/init-agent-workspace.mjs && node --check scripts/agent-issue-intake.mjs && node --check scripts/agent-issue-intake.test.mjs && node --check scripts/core-doctor.mjs && node --check scripts/core-doctor.test.mjs", "check": "pnpm check:root && pnpm typecheck && pnpm test && pnpm build", "agent:workspace": "node scripts/init-agent-workspace.mjs", - "agent:issue:intake": "node scripts/agent-issue-intake.mjs" + "agent:issue:intake": "node scripts/agent-issue-intake.mjs", + "core:doctor": "node scripts/core-doctor.mjs" }, "devDependencies": { "@playwright/test": "^1.63.0", diff --git a/scripts/core-doctor.mjs b/scripts/core-doctor.mjs new file mode 100644 index 0000000..dcb3007 --- /dev/null +++ b/scripts/core-doctor.mjs @@ -0,0 +1,714 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseEnv } from "node:util"; + +export const CORE_DOCTOR_EXIT_CODES = Object.freeze({ + ok: 0, + diagnosticFailure: 1, + usageOrInternalError: 2, +}); + +const DEFAULT_TARGET = "http://127.0.0.1:8091"; +const DEFAULT_TOKEN_FILE = "~/.parsar/agents-api/web-token"; +const DEFAULT_PROFILE = "default"; +const DEFAULT_TIMEOUT_MS = 3_000; +const MAX_CONFIG_BYTES = 1024 * 1024; +const MAX_RESPONSE_BYTES = 1024 * 1024; +const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024; +const PROFILE_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/; +const CONFIG_KEYS = new Set([ + "AGENTS_API_KEYS_FILE", + "AGENTS_API_PROXY_TARGET", + "AGENTS_API_PROXY_TOKEN", + "AGENTS_API_PROXY_TOKEN_FILE", +]); +const PATH_CONFIG_KEYS = new Set(["AGENTS_API_KEYS_FILE", "AGENTS_API_PROXY_TOKEN_FILE"]); +const SAFE_PATH_EXPANSION_KEYS = new Set(["HOME", "PARSAR_HOME"]); + +const HELP = `Agents Core Doctor (read-only) + +Usage: + pnpm core:doctor -- [--parsar ] [--profile ] [--timeout-ms ] + +The doctor performs only GET requests. It never creates an Agent, Session, Turn, +or Item, and it never makes a model/provider call. The optional Parsar checkout is +used only to run the upstream daemon status command. No credential value, response +body, daemon output, or private filesystem path is printed. + +Exit codes: + 0 Core liveness and an authenticated basic Agents API read succeeded. + A daemon may still be unobserved and execution/provider readiness is unknown. + 1 An actionable local configuration or Core connectivity/authentication check failed. + 2 Command usage is invalid or the doctor could not complete safely. +`; + +class CoreDoctorUsageError extends Error {} + +function optionValue(argv, index, option) { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new CoreDoctorUsageError(`${option} requires a value`); + } + return value; +} + +export function parseCoreDoctorArgs(argv) { + let parsarPath; + let profile = DEFAULT_PROFILE; + let timeoutMs = DEFAULT_TIMEOUT_MS; + let help = false; + const positional = []; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--") continue; + if (argument === "-h" || argument === "--help") { + help = true; + continue; + } + if (argument === "--parsar") { + parsarPath = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument.startsWith("--parsar=")) { + parsarPath = argument.slice("--parsar=".length); + continue; + } + if (argument === "--profile") { + profile = optionValue(argv, index, argument); + index += 1; + continue; + } + if (argument.startsWith("--profile=")) { + profile = argument.slice("--profile=".length); + continue; + } + if (argument === "--timeout-ms") { + timeoutMs = Number(optionValue(argv, index, argument)); + index += 1; + continue; + } + if (argument.startsWith("--timeout-ms=")) { + timeoutMs = Number(argument.slice("--timeout-ms=".length)); + continue; + } + if (argument.startsWith("-")) { + throw new CoreDoctorUsageError("unknown option"); + } + positional.push(argument); + } + + if (positional.length > 1 || (positional.length === 1 && parsarPath)) { + throw new CoreDoctorUsageError("provide at most one Parsar checkout"); + } + if (positional.length === 1) parsarPath = positional[0]; + if (typeof parsarPath === "string" && parsarPath.trim() === "") { + throw new CoreDoctorUsageError("Parsar checkout cannot be empty"); + } + if (!PROFILE_PATTERN.test(profile)) { + throw new CoreDoctorUsageError("invalid daemon profile name"); + } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60_000) { + throw new CoreDoctorUsageError("timeout must be between 100 and 60000 milliseconds"); + } + + return { help, parsarPath, profile, timeoutMs }; +} + +function resolveConfiguredPath(configuredPath, homeDir, rootDir) { + if (configuredPath === "~") return homeDir; + if (configuredPath.startsWith("~/")) return join(homeDir, configuredPath.slice(2)); + return isAbsolute(configuredPath) ? configuredPath : resolve(rootDir, configuredPath); +} + +async function readSmallText(path, maximumBytes = MAX_CONFIG_BYTES) { + const metadata = await stat(path); + if (!metadata.isFile() || metadata.size > maximumBytes) throw new Error("unsafe file"); + return readFile(path, "utf8"); +} + +function expandDotEnvValue(value, processEnv, runningParsed) { + const environment = { ...runningParsed, ...processEnv }; + const expressionPattern = /(? !SAFE_PATH_EXPANSION_KEYS.has(name))) { + throw new CoreDoctorUsageError("unsafe variable expansion in local Core configuration"); + } + const safeEnvironment = Object.fromEntries([...SAFE_PATH_EXPANSION_KEYS].flatMap((name) => + typeof env[name] === "string" ? [[name, env[name]]] : [], + )); + return expandDotEnvValue(value, safeEnvironment, {}).replace(/\\\$/g, "$"); +} + +export async function loadCoreDoctorConfig({ env, cwd }) { + const parsed = {}; + const dotenvFiles = [".env", ".env.local", ".env.development", ".env.development.local"]; + + for (const name of dotenvFiles) { + try { + Object.assign(parsed, parseEnv(await readSmallText(join(cwd, name)))); + } catch (error) { + if (error?.code !== "ENOENT") throw new CoreDoctorUsageError("local environment file is unreadable or unsafe"); + } + } + + const loaded = {}; + for (const key of CONFIG_KEYS) { + if (Object.hasOwn(env, key)) loaded[key] = String(env[key] ?? ""); + else if (Object.hasOwn(parsed, key)) loaded[key] = expandConfiguredValue(key, parsed[key], env); + } + return loaded; +} + +function parseCoreTarget(value) { + let target; + try { + target = new URL(value.trim()); + } catch { + throw new CoreDoctorUsageError("Core proxy target is not a valid URL"); + } + + if ( + (target.protocol !== "http:" && target.protocol !== "https:") || + !target.hostname || + target.username || + target.password || + target.search || + target.hash || + (target.pathname !== "" && target.pathname !== "/") + ) { + throw new CoreDoctorUsageError("Core proxy target must be a credential-free HTTP(S) origin"); + } + + const hostname = target.hostname.toLowerCase(); + const loopback = + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "[::1]" || + /^127(?:\.\d{1,3}){3}$/.test(hostname); + if (target.protocol === "http:" && !loopback) { + throw new CoreDoctorUsageError("remote Core proxy targets must use HTTPS"); + } + + return { + displayOrigin: loopback ? target.origin : "remote HTTPS origin", + healthUrl: new URL("/healthz", target.origin).href, + agentsUrl: new URL("/v1/agents?limit=1", target.origin).href, + }; +} + +function createReport() { + const checks = []; + return { + add(level, layer, message) { + checks.push({ level, layer, message }); + }, + render(exitCode) { + const lines = ["Agents Core Doctor (read-only)", ""]; + for (const check of checks) lines.push(`[${check.level}] ${check.layer}: ${check.message}`); + lines.push(""); + if (exitCode === CORE_DOCTOR_EXIT_CODES.ok) { + lines.push("Result: Core API checks passed; execution readiness remains unknown."); + } else if (exitCode === CORE_DOCTOR_EXIT_CODES.diagnosticFailure) { + lines.push("Result: actionable local configuration or Core check failures were found."); + } else { + lines.push("Result: the doctor could not complete because invocation or configuration is invalid."); + } + return `${lines.join("\n")}\n`; + }, + checks, + }; +} + +function normalizeToken(rawToken) { + const token = rawToken.trim(); + if (!token || token.length > 64 * 1024 || /\s/.test(token)) return undefined; + return token; +} + +async function inspectPrivateFile(path, label, { platform, report, required = true }) { + let metadata; + try { + metadata = await stat(path); + } catch (error) { + if (!required && error?.code === "ENOENT") { + report.add("UNKNOWN", label, "local file is not available; digest comparison was skipped."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.ok, value: undefined }; + } + report.add("FAIL", label, "file is missing or unreadable."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, value: undefined }; + } + if (!metadata.isFile() || metadata.size > MAX_CONFIG_BYTES) { + report.add("FAIL", label, "path is not a bounded regular file."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, value: undefined }; + } + if (platform !== "win32" && (metadata.mode & 0o077) !== 0) { + report.add("FAIL", label, "file is group/world accessible; use owner-only permissions."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, value: undefined }; + } + try { + const value = await readFile(path, "utf8"); + report.add("PASS", label, "file is present with private permissions."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.ok, value }; + } catch { + report.add("FAIL", label, "file is missing or unreadable."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, value: undefined }; + } +} + +function digestMatchesBinding(token, keysSource) { + let bindings; + try { + bindings = JSON.parse(keysSource); + } catch { + return false; + } + if (!Array.isArray(bindings)) return false; + const digest = createHash("sha256").update(token).digest("hex"); + return bindings.some((binding) => + binding && + typeof binding === "object" && + typeof binding.token_sha256 === "string" && + /^[a-fA-F0-9]{64}$/.test(binding.token_sha256) && + binding.token_sha256.toLowerCase() === digest, + ); +} + +export async function inspectCoreCredentials({ config, cwd, homeDir, platform, report }) { + const configuredToken = config.AGENTS_API_PROXY_TOKEN?.trim() ?? ""; + const configuredTokenFile = config.AGENTS_API_PROXY_TOKEN_FILE?.trim() ?? ""; + let exitCode = CORE_DOCTOR_EXIT_CODES.ok; + let token; + let tokenFile; + + if (configuredToken && configuredTokenFile) { + report.add("FAIL", "Caller token", "choose only one server-side token source."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, token: undefined }; + } + + if (configuredToken) { + token = normalizeToken(configuredToken); + if (token) report.add("PASS", "Caller token", "server-process token is configured in memory."); + else { + report.add("FAIL", "Caller token", "server-process token is empty or malformed."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + } else { + tokenFile = resolveConfiguredPath(configuredTokenFile || DEFAULT_TOKEN_FILE, homeDir, cwd); + const tokenInspection = await inspectPrivateFile(tokenFile, "Caller token", { platform, report }); + exitCode = Math.max(exitCode, tokenInspection.exitCode); + if (tokenInspection.value !== undefined) { + token = normalizeToken(tokenInspection.value); + if (!token) { + report.add("FAIL", "Caller token", "file does not contain one valid bearer token."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + } + } + + const configuredKeysFile = config.AGENTS_API_KEYS_FILE?.trim(); + let keysFile; + if (configuredKeysFile) keysFile = resolveConfiguredPath(configuredKeysFile, homeDir, cwd); + else if (tokenFile) keysFile = join(dirname(tokenFile), "keys.json"); + + if (!keysFile) { + report.add("UNKNOWN", "Caller binding", "no local keys file is configured; digest comparison was skipped."); + return { exitCode, token }; + } + + const keysInspection = await inspectPrivateFile(keysFile, "Caller binding", { + platform, + report, + required: Boolean(configuredKeysFile), + }); + exitCode = Math.max(exitCode, keysInspection.exitCode); + + if (keysInspection.value === undefined) { + return { exitCode, token }; + } + if (!token) { + report.add("WARN", "Caller binding", "digest comparison skipped because no valid caller token is available."); + } else if (digestMatchesBinding(token, keysInspection.value)) { + report.add("PASS", "Caller binding", "caller token digest matches a keys.json binding."); + } else { + report.add("FAIL", "Caller binding", "caller token digest does not match any keys.json binding."); + exitCode = Math.max(exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + } + + return { exitCode, token }; +} + +async function readJsonResponse(response) { + if (!response.body) throw new Error("missing body"); + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { + await response.body.cancel(); + throw new Error("response too large"); + } + + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_RESPONSE_BYTES) throw new Error("response too large"); + chunks.push(value); + } + } catch (error) { + await reader.cancel().catch(() => {}); + throw error; + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function discardResponse(response) { + await response.body?.cancel().catch(() => {}); +} + +async function fetchOnce(fetchImpl, url, init, timeoutMs) { + return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs), redirect: "error" }); +} + +export async function probeCore({ target, token, timeoutMs, fetchImpl, report }) { + let exitCode = CORE_DOCTOR_EXIT_CODES.ok; + let reachable = false; + + try { + const response = await fetchOnce(fetchImpl, target.healthUrl, { method: "GET" }, timeoutMs); + reachable = true; + if (response.ok) { + let health; + try { + health = await readJsonResponse(response); + } catch { + health = undefined; + } + if (health?.status === "ok") report.add("PASS", "Core liveness", "Core health endpoint responded ok."); + else { + report.add("FAIL", "Core liveness", "Core health response did not match the expected contract."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + } else { + await discardResponse(response); + report.add("FAIL", "Core liveness", `Core health endpoint returned HTTP ${response.status}.`); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + } catch { + report.add("FAIL", "Core liveness", "Core is unreachable or the liveness request timed out."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + + if (!token) { + report.add("WARN", "Core API", "authenticated read skipped because no valid caller token is available."); + return { exitCode: Math.max(exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure) }; + } + if (!reachable) { + report.add("WARN", "Core API", "authenticated read skipped because Core was unreachable."); + return { exitCode }; + } + + try { + const response = await fetchOnce( + fetchImpl, + target.agentsUrl, + { + method: "GET", + headers: { + accept: "application/json", + authorization: `Bearer ${token}`, + "openai-beta": "agents=v1", + }, + }, + timeoutMs, + ); + + if (response.ok) { + let payload; + try { + payload = await readJsonResponse(response); + } catch { + payload = undefined; + } + if (payload?.object === "list" && Array.isArray(payload.data)) { + report.add("PASS", "Core API", "Core API authenticated; basic Agents read succeeded."); + } else { + report.add("FAIL", "Core API", "authenticated response did not match the expected list contract."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + } else { + await discardResponse(response); + if (response.status === 401 || response.status === 403) { + report.add("FAIL", "Core API", `authentication was rejected (HTTP ${response.status}).`); + } else if (response.status === 400) { + report.add("FAIL", "Core API", "Agents protocol headers were rejected (HTTP 400)."); + } else if (response.status === 404 || response.status === 405) { + report.add("FAIL", "Core API", `basic Agents read is unavailable (HTTP ${response.status}).`); + } else { + report.add("FAIL", "Core API", `basic Agents read failed (HTTP ${response.status}).`); + } + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + } catch { + report.add("FAIL", "Core API", "authenticated read failed or timed out."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + + return { exitCode }; +} + +function minimalCommandEnvironment(env) { + const allowed = ["GOCACHE", "GOENV", "GOMODCACHE", "GOPATH", "GOROOT", "HOME", "PARSAR_HOME", "PATH", "TMPDIR"]; + return Object.fromEntries(allowed.flatMap((name) => + typeof env[name] === "string" && env[name] !== "" ? [[name, env[name]]] : [], + )); +} + +async function spawnCommand({ command, args, cwd, env, timeoutMs }) { + return new Promise((resolveResult) => { + const child = spawn(command, args, { + cwd, + env, + shell: false, + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); + const stdout = []; + let outputBytes = 0; + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveResult({ + stdout: Buffer.concat(stdout).toString("utf8"), + ...result, + }); + }; + const collect = (chunks) => (chunk) => { + outputBytes += chunk.byteLength; + if (outputBytes > MAX_COMMAND_OUTPUT_BYTES) { + child.kill("SIGTERM"); + finish({ code: null, outputLimit: true }); + } else { + chunks.push(chunk); + } + }; + child.stdout.on("data", collect(stdout)); + child.once("error", (error) => finish({ code: null, errorCode: error.code })); + child.once("close", (code) => finish({ code })); + const timer = setTimeout(() => { + child.kill("SIGTERM"); + finish({ code: null, timedOut: true }); + }, timeoutMs); + }); +} + +export function parseDaemonStatus(source) { + if (/^paired\s*:\s*ERROR\b/im.test(source) || /^background\s*:\s*ERROR\b/im.test(source)) return "unknown"; + const paired = /^paired\s*:\s*yes\s*$/im.test(source); + const unpaired = /^paired\s*:\s*no legacy profile\b/im.test(source); + const background = /^background\s*:\s*pidfile present\b/im.test(source); + const absent = /^background\s*:\s*not started\b/im.test(source); + if (paired && background) return "paired-background-observed"; + if (paired && absent) return "paired-process-not-observed"; + if (unpaired && (absent || !background)) return "not-observed"; + return "unknown"; +} + +async function validateParsarCheckout(parsarPath) { + try { + const root = resolve(parsarPath); + const [rootMetadata, moduleMetadata, commandMetadata] = await Promise.all([ + stat(root), + stat(join(root, "go.mod")), + stat(join(root, "apps/parsar-daemon/cmd/parsar-daemon/main.go")), + ]); + if (!rootMetadata.isDirectory() || !moduleMetadata.isFile() || !commandMetadata.isFile()) return undefined; + return root; + } catch { + return undefined; + } +} + +export async function inspectDaemon({ parsarPath, profile, timeoutMs, env, runCommand, report }) { + let command; + let args; + let cwd; + + if (parsarPath) { + cwd = await validateParsarCheckout(parsarPath); + if (!cwd) { + report.add("FAIL", "Daemon", "the supplied Parsar checkout is invalid or unreadable."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.usageOrInternalError }; + } + command = "go"; + args = ["run", "./apps/parsar-daemon/cmd/parsar-daemon", "status", "--profile", profile]; + } else { + command = "parsar-daemon"; + args = ["status", "--profile", profile]; + } + + const result = await runCommand({ + command, + args, + cwd, + env: minimalCommandEnvironment(env), + timeoutMs: parsarPath ? Math.max(timeoutMs, 15_000) : timeoutMs, + }); + + if (result.code !== 0 || result.timedOut || result.outputLimit || result.errorCode) { + report.add("WARN", "Daemon", "daemon profile/process status was not observed."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.ok }; + } + + const status = parseDaemonStatus(result.stdout); + if (status === "paired-background-observed") { + report.add("OBSERVED", "Daemon", "paired profile and pid file reported; process and connection remain unknown."); + } else if (status === "paired-process-not-observed") { + report.add("WARN", "Daemon", "profile is paired, but a daemon process was not observed."); + } else if (status === "not-observed") { + report.add("WARN", "Daemon", "daemon profile/process was not observed."); + } else { + report.add("WARN", "Daemon", "upstream status was inconclusive; daemon connection is unknown."); + } + return { exitCode: CORE_DOCTOR_EXIT_CODES.ok }; +} + +export async function runCoreDoctor({ + argv = process.argv.slice(2), + env = process.env, + cwd = process.cwd(), + homeDir = homedir(), + platform = process.platform, + fetchImpl = globalThis.fetch, + runCommand = spawnCommand, + stdout = process.stdout, + stderr = process.stderr, +} = {}) { + let options; + try { + options = parseCoreDoctorArgs(argv); + } catch { + stderr.write("Invalid Core Doctor options. Run `pnpm core:doctor -- --help`.\n"); + return { exitCode: CORE_DOCTOR_EXIT_CODES.usageOrInternalError, checks: [] }; + } + if (options.help) { + stdout.write(HELP); + return { exitCode: CORE_DOCTOR_EXIT_CODES.ok, checks: [] }; + } + + const report = createReport(); + let exitCode = CORE_DOCTOR_EXIT_CODES.ok; + let config; + let configLoaded = true; + try { + config = await loadCoreDoctorConfig({ env, cwd }); + } catch { + report.add("FAIL", "Configuration", "local environment configuration is unreadable or unsafe."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + config = {}; + configLoaded = false; + } + + let target; + if (configLoaded) { + try { + target = parseCoreTarget(config.AGENTS_API_PROXY_TARGET || DEFAULT_TARGET); + report.add("PASS", "Configuration", `proxy target is configured (${target.displayOrigin}).`); + } catch { + report.add("FAIL", "Configuration", "proxy target must be credential-free HTTPS or a loopback HTTP origin."); + exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; + } + } + + const credentials = await inspectCoreCredentials({ config, cwd, homeDir, platform, report }); + exitCode = Math.max(exitCode, credentials.exitCode); + + if (target) { + const core = await probeCore({ + target, + token: credentials.token, + timeoutMs: options.timeoutMs, + fetchImpl, + report, + }); + exitCode = Math.max(exitCode, core.exitCode); + } else { + report.add("WARN", "Core liveness", "network checks skipped because the proxy target is invalid."); + report.add("WARN", "Core API", "authenticated read skipped because the proxy target is invalid."); + } + + const daemon = await inspectDaemon({ + parsarPath: options.parsarPath, + profile: options.profile, + timeoutMs: options.timeoutMs, + env, + runCommand, + report, + }); + exitCode = Math.max(exitCode, daemon.exitCode); + report.add("UNKNOWN", "Execution", "executor, model, and provider readiness were not verified."); + + stdout.write(report.render(exitCode)); + return { exitCode, checks: report.checks }; +} + +const invokedUrl = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedUrl) { + runCoreDoctor() + .then(({ exitCode }) => { + process.exitCode = exitCode; + }) + .catch(() => { + process.stderr.write("Core Doctor could not complete safely.\n"); + process.exitCode = CORE_DOCTOR_EXIT_CODES.usageOrInternalError; + }); +} diff --git a/scripts/core-doctor.test.mjs b/scripts/core-doctor.test.mjs new file mode 100644 index 0000000..daf7a52 --- /dev/null +++ b/scripts/core-doctor.test.mjs @@ -0,0 +1,466 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + CORE_DOCTOR_EXIT_CODES, + parseDaemonStatus, + runCoreDoctor, +} from "./core-doctor.mjs"; + +const fixtureRoot = new URL("./fixtures/core-doctor/", import.meta.url); +const fixtureToken = "fixture-bearer"; +const fixtureTarget = "https://core.fixture.invalid"; + +async function fixture(name) { + return readFile(new URL(name, fixtureRoot), "utf8"); +} + +function captureStream() { + let value = ""; + return { + stream: { + write(chunk) { + value += String(chunk); + return true; + }, + }, + value: () => value, + }; +} + +async function createLocalState(t, { keysFixture = "keys-matched.json", token = fixtureToken } = {}) { + const root = await mkdtemp(join(tmpdir(), "agents-core-doctor-")); + t.after(() => rm(root, { recursive: true, force: true })); + const homeDir = join(root, "home"); + const stateDir = join(homeDir, ".parsar", "agents-api"); + await mkdir(stateDir, { recursive: true, mode: 0o700 }); + await writeFile(join(stateDir, "web-token"), token, { mode: 0o600 }); + await writeFile(join(stateDir, "keys.json"), await fixture(keysFixture), { mode: 0o600 }); + await chmod(join(stateDir, "web-token"), 0o600); + await chmod(join(stateDir, "keys.json"), 0o600); + return { root, homeDir, stateDir }; +} + +async function successfulFetchRecorder({ apiStatus = 200, apiBody, healthStatus = 200, healthBody } = {}) { + const requests = []; + const agentsBody = apiBody ?? await fixture("agents-list.json"); + const fetchImpl = async (url, init = {}) => { + const parsed = new URL(url); + requests.push({ + url: parsed, + method: init.method, + headers: new Headers(init.headers), + redirect: init.redirect, + }); + if (parsed.pathname === "/healthz") { + return new Response(healthBody ?? JSON.stringify({ status: "ok" }), { + status: healthStatus, + headers: { "content-type": "application/json" }, + }); + } + return new Response(agentsBody, { + status: apiStatus, + headers: { "content-type": "application/json" }, + }); + }; + return { fetchImpl, requests }; +} + +async function runScenario({ + argv = [], + env = {}, + cwd, + homeDir, + fetchImpl, + runCommand = async () => ({ code: 0, stdout: await fixture("daemon-status-absent.txt"), stderr: "" }), +} = {}) { + const stdout = captureStream(); + const stderr = captureStream(); + const result = await runCoreDoctor({ + argv, + env, + cwd, + homeDir, + platform: "darwin", + fetchImpl, + runCommand, + stdout: stdout.stream, + stderr: stderr.stream, + }); + return { result, stdout: stdout.value(), stderr: stderr.value() }; +} + +test("documents the read-only command and exit-code contract", async () => { + const result = await runScenario({ + argv: ["--help"], + env: {}, + cwd: process.cwd(), + homeDir: tmpdir(), + fetchImpl: async () => assert.fail("help must not make a request"), + runCommand: async () => assert.fail("help must not inspect a daemon"), + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); + assert.match(result.stdout, /performs only GET requests/); + assert.match(result.stdout, /Exit codes:\n 0[\s\S]*\n 1[\s\S]*\n 2/); + assert.equal(result.stderr, ""); +}); + +test("authenticates a basic GET without leaking the token or daemon output", async (t) => { + const state = await createLocalState(t); + const { fetchImpl, requests } = await successfulFetchRecorder(); + const daemonOutput = await fixture("daemon-status-paired.txt"); + let commandCall; + const result = await runScenario({ + env: { + AGENTS_API_PROXY_TARGET: fixtureTarget, + OPENAI_API_KEY: "provider-secret-marker", + PATH: "/synthetic/bin", + HOME: state.homeDir, + }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + runCommand: async (call) => { + commandCall = call; + return { code: 0, stdout: daemonOutput, stderr: "runner_credential=fixture-super-secret-token" }; + }, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); + assert.match(result.stdout, /Core API authenticated; basic Agents read succeeded/); + assert.match(result.stdout, /paired profile and pid file reported; process and connection remain unknown/); + assert.doesNotMatch(result.stdout, /\[PASS\] Daemon/); + assert.match(result.stdout, /executor, model, and provider readiness were not verified/); + assert.doesNotMatch(result.stdout, new RegExp(fixtureToken)); + assert.doesNotMatch(result.stdout, /synthetic\/private|synthetic-runtime|synthetic-host/); + assert.equal(result.stderr, ""); + assert.deepEqual(requests.map(({ method }) => method), ["GET", "GET"]); + assert.deepEqual(requests.map(({ url }) => `${url.pathname}${url.search}`), ["/healthz", "/v1/agents?limit=1"]); + assert.equal(requests[0].headers.has("authorization"), false); + assert.equal(requests[1].headers.get("authorization"), `Bearer ${fixtureToken}`); + assert.equal(requests[1].headers.get("openai-beta"), "agents=v1"); + assert.equal(requests[1].redirect, "error"); + assert.deepEqual(commandCall.args, ["status", "--profile", "default"]); + assert.equal(commandCall.env.OPENAI_API_KEY, undefined); +}); + +test("reports missing conventional credential files and skips the authenticated read", async (t) => { + const root = await mkdtemp(join(tmpdir(), "agents-core-doctor-missing-")); + t.after(() => rm(root, { recursive: true, force: true })); + const { fetchImpl, requests } = await successfulFetchRecorder(); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: root, + homeDir: root, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /Caller token: file is missing or unreadable/); + assert.match(result.stdout, /Caller binding: local file is not available; digest comparison was skipped/); + assert.match(result.stdout, /authenticated read skipped because no valid caller token/); + assert.equal(requests.length, 1); +}); + +test("refuses to read a group/world-accessible token file", async (t) => { + const state = await createLocalState(t); + await chmod(join(state.stateDir, "web-token"), 0o644); + const { fetchImpl, requests } = await successfulFetchRecorder(); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /Caller token: file is group\/world accessible/); + assert.equal(requests.length, 1); + assert.doesNotMatch(result.stdout, new RegExp(state.stateDir.replaceAll("/", "\\/"))); +}); + +test("fails when an available local keys file has unsafe permissions", async (t) => { + const state = await createLocalState(t); + await chmod(join(state.stateDir, "keys.json"), 0o644); + const { fetchImpl, requests } = await successfulFetchRecorder(); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /Caller binding: file is group\/world accessible/); + assert.equal(requests.length, 2); +}); + +test("detects a caller digest mismatch while keeping the GET probe read-only", async (t) => { + const state = await createLocalState(t, { keysFixture: "keys-mismatch.json" }); + const { fetchImpl, requests } = await successfulFetchRecorder(); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /does not match any keys\.json binding/); + assert.equal(requests.length, 2); + assert.ok(requests.every(({ method }) => method === "GET")); +}); + +test("allows an in-memory caller token when no local keys file is configured", async (t) => { + const root = await mkdtemp(join(tmpdir(), "agents-core-doctor-inline-")); + t.after(() => rm(root, { recursive: true, force: true })); + const { fetchImpl, requests } = await successfulFetchRecorder(); + const result = await runScenario({ + env: { + AGENTS_API_PROXY_TARGET: fixtureTarget, + AGENTS_API_PROXY_TOKEN: fixtureToken, + }, + cwd: root, + homeDir: root, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); + assert.match(result.stdout, /no local keys file is configured; digest comparison was skipped/); + assert.match(result.stdout, /Core API authenticated; basic Agents read succeeded/); + assert.equal(requests.length, 2); +}); + +test("treats conflicting server-side token sources as an actionable configuration failure", async (t) => { + const state = await createLocalState(t); + const { fetchImpl } = await successfulFetchRecorder(); + const result = await runScenario({ + env: { + AGENTS_API_PROXY_TARGET: fixtureTarget, + AGENTS_API_PROXY_TOKEN: fixtureToken, + AGENTS_API_PROXY_TOKEN_FILE: join(state.stateDir, "web-token"), + }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /choose only one server-side token source/); +}); + +test("distinguishes an unreachable Core without retrying", async (t) => { + const state = await createLocalState(t); + let calls = 0; + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: "http://127.0.0.1:1" }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl: async () => { + calls += 1; + throw new TypeError("synthetic connection refused"); + }, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /Core is unreachable or the liveness request timed out/); + assert.match(result.stdout, /authenticated read skipped because Core was unreachable/); + assert.equal(calls, 1); +}); + +test("distinguishes a 401 from liveness and discards the response body", async (t) => { + const state = await createLocalState(t); + const { fetchImpl } = await successfulFetchRecorder({ + apiStatus: 401, + apiBody: JSON.stringify({ error: { message: "session-private-marker", code: "invalid_api_key" } }), + }); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /authentication was rejected \(HTTP 401\)/); + assert.doesNotMatch(result.stdout, /session-private-marker|invalid_api_key/); +}); + +test("loads Vite-style proxy dotenv configuration without exposing unrelated values", async (t) => { + const state = await createLocalState(t); + const { fetchImpl, requests } = await successfulFetchRecorder(); + await writeFile(join(state.root, ".env"), "AGENTS_API_PROXY_TARGET=https://base.fixture.invalid\n"); + await writeFile( + join(state.root, ".env.local"), + [ + "AGENTS_API_PROXY_TARGET='https://dotenv.fixture.invalid' # local override", + "AGENTS_API_PROXY_TOKEN_FILE=${HOME}/.parsar/agents-api/web-token", + "AGENTS_API_KEYS_FILE=\"${HOME}/.parsar/agents-api/keys.json\" # quoted path", + "OPENAI_API_KEY=provider-secret-marker", + ].join("\n"), + { mode: 0o600 }, + ); + const result = await runScenario({ + env: { HOME: state.homeDir }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); + assert.equal(requests[0].url.origin, "https://dotenv.fixture.invalid"); + assert.doesNotMatch(result.stdout, /provider-secret-marker/); +}); + +test("fails closed when a network target tries to expand an unrelated secret", async (t) => { + const state = await createLocalState(t); + await writeFile( + join(state.root, ".env.local"), + [ + "OPENAI_API_KEY=provider-secret-marker", + "AGENTS_API_PROXY_TARGET=https://${OPENAI_API_KEY}.invalid", + "AGENTS_API_PROXY_TOKEN_FILE=${HOME}/.parsar/agents-api/web-token", + ].join("\n"), + { mode: 0o600 }, + ); + let requests = 0; + const result = await runScenario({ + env: { HOME: state.homeDir }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl: async () => { + requests += 1; + return new Response(JSON.stringify({ status: "ok" })); + }, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.equal(requests, 0); + assert.match(result.stdout, /local environment configuration is unreadable or unsafe/); + assert.doesNotMatch(result.stdout, /provider-secret-marker|OPENAI_API_KEY/); +}); + +test("uses an explicit Parsar checkout only for an allowlisted daemon status command", async (t) => { + const state = await createLocalState(t); + const parsarPath = join(state.root, "private-parsar-checkout"); + await mkdir(join(parsarPath, "apps", "parsar-daemon", "cmd", "parsar-daemon"), { recursive: true }); + await writeFile(join(parsarPath, "go.mod"), "module fixture.invalid/parsar\n"); + await writeFile(join(parsarPath, "apps", "parsar-daemon", "cmd", "parsar-daemon", "main.go"), "package main\n"); + const { fetchImpl } = await successfulFetchRecorder(); + let commandCall; + const result = await runScenario({ + argv: ["--parsar", parsarPath, "--profile", "fixture-profile"], + env: { + AGENTS_API_PROXY_TARGET: fixtureTarget, + HOME: state.homeDir, + PATH: "/synthetic/bin", + OPENAI_API_KEY: "provider-secret-marker", + }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + runCommand: async (call) => { + commandCall = call; + return { code: 0, stdout: await fixture("daemon-status-absent.txt"), stderr: "" }; + }, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); + assert.equal(commandCall.command, "go"); + assert.equal(commandCall.cwd, parsarPath); + assert.deepEqual(commandCall.args, [ + "run", + "./apps/parsar-daemon/cmd/parsar-daemon", + "status", + "--profile", + "fixture-profile", + ]); + assert.equal(commandCall.env.OPENAI_API_KEY, undefined); + assert.doesNotMatch(result.stdout, /private-parsar-checkout|fixture-profile/); +}); + +test("parses only allowlisted daemon state and treats absence as non-fatal", async () => { + assert.equal(parseDaemonStatus(await fixture("daemon-status-paired.txt")), "paired-background-observed"); + assert.equal(parseDaemonStatus(await fixture("daemon-status-absent.txt")), "not-observed"); + assert.equal(parseDaemonStatus("paired: ERROR — /synthetic/private/error"), "unknown"); +}); + +test("never includes credential, response, URL suffix, provider, or private-path markers", async (t) => { + const root = await mkdtemp(join(tmpdir(), "agents-core-doctor-redaction-")); + t.after(() => rm(root, { recursive: true, force: true })); + const privateDir = join(root, "synthetic-private-doctor-state"); + await mkdir(privateDir, { recursive: true, mode: 0o700 }); + const token = "fixture-super-secret-token"; + const digest = createHash("sha256").update(token).digest("hex"); + const keysPath = join(privateDir, "keys.json"); + await writeFile(keysPath, JSON.stringify([{ token_sha256: digest }]), { mode: 0o600 }); + await chmod(keysPath, 0o600); + const { fetchImpl } = await successfulFetchRecorder({ + apiBody: JSON.stringify({ + object: "list", + data: [{ id: "agent_fixture", name: "session-private-marker" }], + }), + }); + const result = await runScenario({ + env: { + AGENTS_API_PROXY_TARGET: fixtureTarget, + AGENTS_API_PROXY_TOKEN: token, + AGENTS_API_KEYS_FILE: keysPath, + OPENAI_API_KEY: "provider-secret-marker", + HOME: root, + PATH: "/synthetic/bin", + }, + cwd: root, + homeDir: root, + fetchImpl, + runCommand: async () => ({ + code: 0, + stdout: await fixture("daemon-status-paired.txt"), + stderr: "Authorization: Bearer fixture-super-secret-token", + }), + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); + const combined = `${result.stdout}\n${result.stderr}`; + for (const marker of JSON.parse(await fixture("redaction-corpus.json"))) { + assert.equal(combined.includes(marker), false, `report leaked marker: ${marker}`); + } + assert.equal(combined.includes(keysPath), false); +}); + +test("rejects credential-bearing target suffixes without reflecting them", async (t) => { + const state = await createLocalState(t); + const result = await runScenario({ + env: { + AGENTS_API_PROXY_TARGET: "https://core.fixture.invalid/?access=query-secret-marker#fragment-secret-marker", + HOME: state.homeDir, + }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl: async () => assert.fail("invalid targets must not be requested"), + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /credential-free HTTPS or a loopback HTTP origin/); + assert.doesNotMatch(result.stdout, /query-secret-marker|fragment-secret-marker/); +}); + +test("invalid options use exit 2 without reflecting untrusted argv", async () => { + const result = await runScenario({ + argv: ["--profile", "../../query-secret-marker"], + env: {}, + cwd: process.cwd(), + homeDir: tmpdir(), + fetchImpl: async () => assert.fail("invalid options must not make a request"), + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.usageOrInternalError); + assert.equal(result.stdout, ""); + assert.match(result.stderr, /Invalid Core Doctor options/); + assert.doesNotMatch(result.stderr, /query-secret-marker/); +}); diff --git a/scripts/fixtures/core-doctor/agents-list.json b/scripts/fixtures/core-doctor/agents-list.json new file mode 100644 index 0000000..8435ede --- /dev/null +++ b/scripts/fixtures/core-doctor/agents-list.json @@ -0,0 +1,7 @@ +{ + "object": "list", + "data": [], + "first_id": null, + "last_id": null, + "has_more": false +} diff --git a/scripts/fixtures/core-doctor/daemon-status-absent.txt b/scripts/fixtures/core-doctor/daemon-status-absent.txt new file mode 100644 index 0000000..c3b9102 --- /dev/null +++ b/scripts/fixtures/core-doctor/daemon-status-absent.txt @@ -0,0 +1,4 @@ +profile : fixture +state dir : /synthetic/private/daemon-state +paired : no legacy profile (use the connect command) +background : not started (no connect.pid) diff --git a/scripts/fixtures/core-doctor/daemon-status-paired.txt b/scripts/fixtures/core-doctor/daemon-status-paired.txt new file mode 100644 index 0000000..cabce45 --- /dev/null +++ b/scripts/fixtures/core-doctor/daemon-status-paired.txt @@ -0,0 +1,7 @@ +profile : fixture +state dir : /synthetic/private/daemon-state +paired : yes +server_url : https://core.fixture.invalid +runtime_id : synthetic-runtime +hostname : synthetic-host +background : pidfile present at /synthetic/private/connect.pid diff --git a/scripts/fixtures/core-doctor/keys-matched.json b/scripts/fixtures/core-doctor/keys-matched.json new file mode 100644 index 0000000..03f5072 --- /dev/null +++ b/scripts/fixtures/core-doctor/keys-matched.json @@ -0,0 +1,10 @@ +[ + { + "tenant_id": "11111111-1111-4111-8111-111111111111", + "organization_id": "fixture-organization", + "project_id": "fixture-project", + "subject_kind": "service_account", + "subject_id": "fixture-doctor", + "token_sha256": "8357b016536bb694eec61516a5c61263b0abd24ac20a7606dcd93fa66ed8cd08" + } +] diff --git a/scripts/fixtures/core-doctor/keys-mismatch.json b/scripts/fixtures/core-doctor/keys-mismatch.json new file mode 100644 index 0000000..e01130b --- /dev/null +++ b/scripts/fixtures/core-doctor/keys-mismatch.json @@ -0,0 +1,10 @@ +[ + { + "tenant_id": "22222222-2222-4222-8222-222222222222", + "organization_id": "fixture-organization", + "project_id": "fixture-project", + "subject_kind": "service_account", + "subject_id": "fixture-mismatch", + "token_sha256": "0000000000000000000000000000000000000000000000000000000000000000" + } +] diff --git a/scripts/fixtures/core-doctor/redaction-corpus.json b/scripts/fixtures/core-doctor/redaction-corpus.json new file mode 100644 index 0000000..6e4d5a6 --- /dev/null +++ b/scripts/fixtures/core-doctor/redaction-corpus.json @@ -0,0 +1,11 @@ +[ + "fixture-super-secret-token", + "Bearer fixture-super-secret-token", + "query-secret-marker", + "fragment-secret-marker", + "provider-secret-marker", + "session-private-marker", + "/synthetic/private/doctor-state", + "/synthetic/private/daemon-state", + "/synthetic/private/connect.pid" +] From b961e8eeed87f11c0d4c3ccfb5c76ec0897d10d1 Mon Sep 17 00:00:00 2001 From: sam2tom Date: Tue, 15 Sep 2026 23:25:11 +0800 Subject: [PATCH 2/4] fix: accept current Agents list page contract --- scripts/core-doctor.mjs | 7 ++++++- scripts/core-doctor.test.mjs | 1 + scripts/fixtures/core-doctor/agents-list.json | 1 - 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/core-doctor.mjs b/scripts/core-doctor.mjs index dcb3007..13a8473 100644 --- a/scripts/core-doctor.mjs +++ b/scripts/core-doctor.mjs @@ -477,7 +477,12 @@ export async function probeCore({ target, token, timeoutMs, fetchImpl, report }) } catch { payload = undefined; } - if (payload?.object === "list" && Array.isArray(payload.data)) { + if ( + payload && + typeof payload === "object" && + Array.isArray(payload.data) && + typeof payload.has_more === "boolean" + ) { report.add("PASS", "Core API", "Core API authenticated; basic Agents read succeeded."); } else { report.add("FAIL", "Core API", "authenticated response did not match the expected list contract."); diff --git a/scripts/core-doctor.test.mjs b/scripts/core-doctor.test.mjs index daf7a52..ce2f1a0 100644 --- a/scripts/core-doctor.test.mjs +++ b/scripts/core-doctor.test.mjs @@ -404,6 +404,7 @@ test("never includes credential, response, URL suffix, provider, or private-path apiBody: JSON.stringify({ object: "list", data: [{ id: "agent_fixture", name: "session-private-marker" }], + has_more: false, }), }); const result = await runScenario({ diff --git a/scripts/fixtures/core-doctor/agents-list.json b/scripts/fixtures/core-doctor/agents-list.json index 8435ede..5a128ac 100644 --- a/scripts/fixtures/core-doctor/agents-list.json +++ b/scripts/fixtures/core-doctor/agents-list.json @@ -1,5 +1,4 @@ { - "object": "list", "data": [], "first_id": null, "last_id": null, From 38b9a26191c62584bcbe8da1f9bb68e61221391b Mon Sep 17 00:00:00 2001 From: sam2tom Date: Wed, 16 Sep 2026 02:20:08 +0800 Subject: [PATCH 3/4] fix: harden Core Doctor contract checks --- scripts/core-doctor.mjs | 196 +++++++++++++++- scripts/core-doctor.test.mjs | 218 +++++++++++++++++- scripts/fixtures/core-doctor/agents-list.json | 1 + 3 files changed, 399 insertions(+), 16 deletions(-) diff --git a/scripts/core-doctor.mjs b/scripts/core-doctor.mjs index 13a8473..63ee0cd 100644 --- a/scripts/core-doctor.mjs +++ b/scripts/core-doctor.mjs @@ -14,6 +14,8 @@ export const CORE_DOCTOR_EXIT_CODES = Object.freeze({ usageOrInternalError: 2, }); +export const PARSAR_PROTOCOL_BASELINE_REVISION = "0438880ab21aa16d05cb91a4c7f91cc0abc12358"; + const DEFAULT_TARGET = "http://127.0.0.1:8091"; const DEFAULT_TOKEN_FILE = "~/.parsar/agents-api/web-token"; const DEFAULT_PROFILE = "default"; @@ -41,6 +43,14 @@ or Item, and it never makes a model/provider call. The optional Parsar checkout used only to run the upstream daemon status command. No credential value, response body, daemon output, or private filesystem path is printed. +The authenticated read validates only the basic Agent resource envelope. Known +tool variants receive basic field validation; additive JSON fields and unknown +nonempty tool-type discriminants are accepted. A passing result does not prove +that the Web supports those tools or complete Parsar protocol compatibility. + +Pinned Parsar Agents API contract: + ${PARSAR_PROTOCOL_BASELINE_REVISION} + Exit codes: 0 Core liveness and an authenticated basic Agents API read succeeded. A daemon may still be unobserved and execution/provider readiness is unknown. @@ -249,11 +259,17 @@ function createReport() { checks.push({ level, layer, message }); }, render(exitCode) { - const lines = ["Agents Core Doctor (read-only)", ""]; + const lines = [ + "Agents Core Doctor (read-only)", + `Parsar protocol baseline: ${PARSAR_PROTOCOL_BASELINE_REVISION}`, + "", + ]; for (const check of checks) lines.push(`[${check.level}] ${check.layer}: ${check.message}`); lines.push(""); if (exitCode === CORE_DOCTOR_EXIT_CODES.ok) { - lines.push("Result: Core API checks passed; execution readiness remains unknown."); + lines.push( + "Result: Core API checks passed for the basic Agent resource envelope; tool/Web compatibility, full protocol compatibility, and execution readiness remain unknown.", + ); } else if (exitCode === CORE_DOCTOR_EXIT_CODES.diagnosticFailure) { lines.push("Result: actionable local configuration or Core check failures were found."); } else { @@ -287,7 +303,11 @@ async function inspectPrivateFile(path, label, { platform, report, required = tr report.add("FAIL", label, "path is not a bounded regular file."); return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, value: undefined }; } - if (platform !== "win32" && (metadata.mode & 0o077) !== 0) { + if (platform === "win32") { + report.add("FAIL", label, "owner-only permissions cannot be verified on this platform; file was not read."); + return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, value: undefined }; + } + if ((metadata.mode & 0o077) !== 0) { report.add("FAIL", label, "file is group/world accessible; use owner-only permissions."); return { exitCode: CORE_DOCTOR_EXIT_CODES.diagnosticFailure, value: undefined }; } @@ -417,6 +437,159 @@ async function fetchOnce(fetchImpl, url, init, timeoutMs) { return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs), redirect: "error" }); } +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isNonEmptyString(value) { + return typeof value === "string" && value.length > 0; +} + +function isNullableString(value) { + return value === null || typeof value === "string"; +} + +function isStringRecord(value) { + return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); +} + +function isCanonicalMultiAgent(value) { + if ( + !isRecord(value) || + !hasOwn(value, "enabled") || + typeof value.enabled !== "boolean" || + !hasOwn(value, "max_concurrent_subagents") + ) { + return false; + } + if (!value.enabled) return value.max_concurrent_subagents === null; + return ( + Number.isSafeInteger(value.max_concurrent_subagents) && + value.max_concurrent_subagents > 0 && + value.max_concurrent_subagents <= 4_294_967_295 + ); +} + +const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]); +const REASONING_SUMMARIES = new Set(["concise", "detailed", "auto"]); +const SERVICE_TIERS = new Set(["auto", "default", "flex", "priority", "fast"]); +const TEXT_VERBOSITIES = new Set(["low", "medium", "high"]); + +function isOptionalNullableEnum(value, key, allowed) { + return !hasOwn(value, key) || value[key] === null || allowed.has(value[key]); +} + +function isCanonicalReasoning(value) { + return ( + isRecord(value) && + isOptionalNullableEnum(value, "effort", REASONING_EFFORTS) && + isOptionalNullableEnum(value, "summary", REASONING_SUMMARIES) + ); +} + +function isCanonicalTextFormat(value) { + if (!isRecord(value) || !hasOwn(value, "type")) return false; + if (value.type === "text") return !hasOwn(value, "schema"); + return value.type === "json_schema" && hasOwn(value, "schema") && isRecord(value.schema); +} + +function isCanonicalText(value) { + return ( + isRecord(value) && + hasOwn(value, "format") && + isCanonicalTextFormat(value.format) && + hasOwn(value, "verbosity") && + TEXT_VERBOSITIES.has(value.verbosity) + ); +} + +function isBasicSavedAgentToolEnvelope(value) { + if (!isRecord(value) || !isNonEmptyString(value.type)) return false; + if (value.type === "tool_search") return true; + if (value.type === "programmatic_tool_calling") { + return hasOwn(value, "enabled") && typeof value.enabled === "boolean"; + } + if (value.type === "function") { + return ( + hasOwn(value, "name") && + typeof value.name === "string" && + hasOwn(value, "description") && + typeof value.description === "string" && + hasOwn(value, "parameters") && + isRecord(value.parameters) && + hasOwn(value, "defer_loading") && + typeof value.defer_loading === "boolean" + ); + } + return true; +} + +function isCanonicalSavedAgent(value) { + const requiredFields = [ + "id", + "object", + "model", + "name", + "instructions", + "metadata", + "multi_agent", + "reasoning", + "service_tier", + "text", + "tools", + "created_at", + "updated_at", + ]; + return ( + isRecord(value) && + requiredFields.every((field) => hasOwn(value, field)) && + isNonEmptyString(value.id) && + value.object === "agent" && + typeof value.model === "string" && + isNullableString(value.name) && + isNullableString(value.instructions) && + isStringRecord(value.metadata) && + isCanonicalMultiAgent(value.multi_agent) && + isCanonicalReasoning(value.reasoning) && + SERVICE_TIERS.has(value.service_tier) && + isCanonicalText(value.text) && + Array.isArray(value.tools) && + value.tools.every(isBasicSavedAgentToolEnvelope) && + Number.isSafeInteger(value.created_at) && + value.created_at >= 0 && + Number.isSafeInteger(value.updated_at) && + value.updated_at >= 0 + ); +} + +function isCanonicalSavedAgentList(value, limit) { + if ( + !isRecord(value) || + value.object !== "list" || + !hasOwn(value, "data") || + !Array.isArray(value.data) || + value.data.length > limit || + !value.data.every(isCanonicalSavedAgent) || + !hasOwn(value, "has_more") || + typeof value.has_more !== "boolean" || + !hasOwn(value, "first_id") || + !hasOwn(value, "last_id") || + !(value.first_id === null || isNonEmptyString(value.first_id)) || + !(value.last_id === null || isNonEmptyString(value.last_id)) + ) { + return false; + } + + if (value.data.length === 0) { + return value.has_more === false && value.first_id === null && value.last_id === null; + } + return value.first_id === value.data[0].id && value.last_id === value.data.at(-1).id; +} + export async function probeCore({ target, token, timeoutMs, fetchImpl, report }) { let exitCode = CORE_DOCTOR_EXIT_CODES.ok; let reachable = false; @@ -424,7 +597,7 @@ export async function probeCore({ target, token, timeoutMs, fetchImpl, report }) try { const response = await fetchOnce(fetchImpl, target.healthUrl, { method: "GET" }, timeoutMs); reachable = true; - if (response.ok) { + if (response.status === 200) { let health; try { health = await readJsonResponse(response); @@ -470,20 +643,19 @@ export async function probeCore({ target, token, timeoutMs, fetchImpl, report }) timeoutMs, ); - if (response.ok) { + if (response.status === 200) { let payload; try { payload = await readJsonResponse(response); } catch { payload = undefined; } - if ( - payload && - typeof payload === "object" && - Array.isArray(payload.data) && - typeof payload.has_more === "boolean" - ) { - report.add("PASS", "Core API", "Core API authenticated; basic Agents read succeeded."); + if (isCanonicalSavedAgentList(payload, 1)) { + report.add( + "PASS", + "Core API", + "Core API authenticated; basic Agent resource envelope parsed.", + ); } else { report.add("FAIL", "Core API", "authenticated response did not match the expected list contract."); exitCode = CORE_DOCTOR_EXIT_CODES.diagnosticFailure; diff --git a/scripts/core-doctor.test.mjs b/scripts/core-doctor.test.mjs index ce2f1a0..d4aeae9 100644 --- a/scripts/core-doctor.test.mjs +++ b/scripts/core-doctor.test.mjs @@ -7,6 +7,7 @@ import test from "node:test"; import { CORE_DOCTOR_EXIT_CODES, + PARSAR_PROTOCOL_BASELINE_REVISION, parseDaemonStatus, runCoreDoctor, } from "./core-doctor.mjs"; @@ -70,11 +71,43 @@ async function successfulFetchRecorder({ apiStatus = 200, apiBody, healthStatus return { fetchImpl, requests }; } +function canonicalAgent(overrides = {}) { + return { + id: "agent_fixture", + object: "agent", + model: "fixture/model", + name: "Fixture Agent", + instructions: null, + metadata: { fixture: "safe" }, + multi_agent: { enabled: false, max_concurrent_subagents: null }, + reasoning: {}, + service_tier: "auto", + text: { format: { type: "text" }, verbosity: "medium" }, + tools: [], + created_at: 1_789_438_200, + updated_at: 1_789_438_800, + ...overrides, + }; +} + +function canonicalAgentPage(agent = canonicalAgent(), overrides = {}) { + const cursor = agent && typeof agent === "object" ? agent.id : null; + return { + object: "list", + data: [agent], + first_id: cursor, + last_id: cursor, + has_more: false, + ...overrides, + }; +} + async function runScenario({ argv = [], env = {}, cwd, homeDir, + platform = "darwin", fetchImpl, runCommand = async () => ({ code: 0, stdout: await fixture("daemon-status-absent.txt"), stderr: "" }), } = {}) { @@ -85,7 +118,7 @@ async function runScenario({ env, cwd, homeDir, - platform: "darwin", + platform, fetchImpl, runCommand, stdout: stdout.stream, @@ -106,6 +139,9 @@ test("documents the read-only command and exit-code contract", async () => { assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); assert.match(result.stdout, /performs only GET requests/); + assert.match(result.stdout, new RegExp(PARSAR_PROTOCOL_BASELINE_REVISION)); + assert.match(result.stdout, /additive JSON fields and unknown\nnonempty tool-type discriminants are accepted/); + assert.match(result.stdout, /does not prove\s+that the Web supports those tools/); assert.match(result.stdout, /Exit codes:\n 0[\s\S]*\n 1[\s\S]*\n 2/); assert.equal(result.stderr, ""); }); @@ -132,7 +168,9 @@ test("authenticates a basic GET without leaking the token or daemon output", asy }); assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); - assert.match(result.stdout, /Core API authenticated; basic Agents read succeeded/); + assert.match(result.stdout, new RegExp(PARSAR_PROTOCOL_BASELINE_REVISION)); + assert.match(result.stdout, /Core API authenticated; basic Agent resource envelope parsed/); + assert.match(result.stdout, /tool\/Web compatibility, full protocol compatibility/); assert.match(result.stdout, /paired profile and pid file reported; process and connection remain unknown/); assert.doesNotMatch(result.stdout, /\[PASS\] Daemon/); assert.match(result.stdout, /executor, model, and provider readiness were not verified/); @@ -232,7 +270,7 @@ test("allows an in-memory caller token when no local keys file is configured", a assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); assert.match(result.stdout, /no local keys file is configured; digest comparison was skipped/); - assert.match(result.stdout, /Core API authenticated; basic Agents read succeeded/); + assert.match(result.stdout, /Core API authenticated; basic Agent resource envelope parsed/); assert.equal(requests.length, 2); }); @@ -291,6 +329,176 @@ test("distinguishes a 401 from liveness and discards the response body", async ( assert.doesNotMatch(result.stdout, /session-private-marker|invalid_api_key/); }); +test("accepts only HTTP 200 for health and authenticated Agents reads", async (t) => { + const state = await createLocalState(t); + for (const status of [202, 206]) { + await t.test(`health HTTP ${status}`, async () => { + const { fetchImpl, requests } = await successfulFetchRecorder({ healthStatus: status }); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, new RegExp(`Core health endpoint returned HTTP ${status}`)); + assert.deepEqual(requests.map(({ method }) => method), ["GET", "GET"]); + }); + + await t.test(`Agents HTTP ${status}`, async () => { + const { fetchImpl, requests } = await successfulFetchRecorder({ apiStatus: status }); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, new RegExp(`basic Agents read failed \\(HTTP ${status}\\)`)); + assert.deepEqual(requests.map(({ method }) => method), ["GET", "GET"]); + }); + } +}); + +test("accepts a canonical non-empty page with additive and unknown tool variants", async (t) => { + const state = await createLocalState(t); + const { fetchImpl, requests } = await successfulFetchRecorder({ + apiBody: JSON.stringify(canonicalAgentPage(canonicalAgent({ + model: "", + multi_agent: { enabled: true, max_concurrent_subagents: 6, additive_nested: true }, + reasoning: { effort: "max", summary: "detailed", additive_nested: true }, + service_tier: "fast", + text: { + format: { type: "json_schema", schema: { type: "object" }, additive_nested: true }, + verbosity: "high", + additive_nested: true, + }, + tools: [ + { type: "function", name: "", description: "", parameters: {}, defer_loading: false }, + { type: "tool_search", additive_nested: true }, + { type: "programmatic_tool_calling", enabled: true }, + { + type: "mcp", + server_label: "records", + transport: { type: "http", server_url: "https://mcp.fixture.invalid/tools", headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + }, + { type: "future_tool", additive_nested: true }, + ], + additive_agent: true, + }), { additive_page: true })), + }); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.ok); + assert.match(result.stdout, /Core API authenticated; basic Agent resource envelope parsed/); + assert.match( + result.stdout, + /tool\/Web compatibility, full protocol compatibility, and execution readiness remain unknown/, + ); + assert.equal(requests.length, 2); +}); + +test("rejects malformed or non-canonical Agents list pages", async (t) => { + const state = await createLocalState(t); + const emptyPage = { + object: "list", + data: [], + first_id: null, + last_id: null, + has_more: false, + }; + const missingModel = canonicalAgent(); + delete missingModel.model; + const scenarios = [ + ["wrong list object", { ...emptyPage, object: "agents" }], + ["missing list object", Object.fromEntries(Object.entries(emptyPage).filter(([key]) => key !== "object"))], + ["null Agent", canonicalAgentPage(null, { first_id: null, last_id: null })], + ["missing Agent field", canonicalAgentPage(missingModel)], + ["wrong Agent object", canonicalAgentPage(canonicalAgent({ object: "agent.snapshot" }))], + ["wrong Agent field type", canonicalAgentPage(canonicalAgent({ created_at: "1789438200" }))], + ["wrong nested Agent field type", canonicalAgentPage(canonicalAgent({ + multi_agent: { enabled: "false", max_concurrent_subagents: null }, + }))], + ["null tool", canonicalAgentPage(canonicalAgent({ tools: [null] }))], + ["missing tool discriminant", canonicalAgentPage(canonicalAgent({ tools: [{}] }))], + ["empty tool discriminant", canonicalAgentPage(canonicalAgent({ tools: [{ type: "" }] }))], + ["incomplete function tool", canonicalAgentPage(canonicalAgent({ + tools: [{ type: "function", name: "lookup", description: "", parameters: {} }], + }))], + ["wrong function tool field type", canonicalAgentPage(canonicalAgent({ + tools: [{ type: "function", name: 3, description: "", parameters: {}, defer_loading: false }], + }))], + ["incomplete programmatic tool", canonicalAgentPage(canonicalAgent({ + tools: [{ type: "programmatic_tool_calling" }], + }))], + ["wrong programmatic tool field type", canonicalAgentPage(canonicalAgent({ + tools: [{ type: "programmatic_tool_calling", enabled: "true" }], + }))], + ["inconsistent disabled multi-agent maximum", canonicalAgentPage(canonicalAgent({ + multi_agent: { enabled: false, max_concurrent_subagents: 4 }, + }))], + ["missing enabled multi-agent maximum", canonicalAgentPage(canonicalAgent({ + multi_agent: { enabled: true, max_concurrent_subagents: null }, + }))], + ["invalid cursor type", { ...emptyPage, first_id: 7 }], + ["empty page with cursor", { ...emptyPage, first_id: "agent_fixture", last_id: "agent_fixture" }], + ["empty page claiming more results", { ...emptyPage, has_more: true }], + ["non-empty page with mismatched cursor", canonicalAgentPage(canonicalAgent(), { last_id: "agent_other" })], + ["missing cursor", Object.fromEntries(Object.entries(emptyPage).filter(([key]) => key !== "last_id"))], + ["more than requested limit", canonicalAgentPage(canonicalAgent(), { + data: [canonicalAgent(), canonicalAgent({ id: "agent_second" })], + last_id: "agent_second", + })], + ]; + + for (const [name, payload] of scenarios) { + await t.test(name, async () => { + const { fetchImpl, requests } = await successfulFetchRecorder({ apiBody: JSON.stringify(payload) }); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /authenticated response did not match the expected list contract/); + assert.equal(requests.length, 2); + }); + } +}); + +test("does not read or authorize from a token file when private permissions cannot be proven", async (t) => { + const state = await createLocalState(t); + const { fetchImpl, requests } = await successfulFetchRecorder(); + const result = await runScenario({ + env: { AGENTS_API_PROXY_TARGET: fixtureTarget }, + cwd: state.root, + homeDir: state.homeDir, + platform: "win32", + fetchImpl, + }); + + assert.equal(result.result.exitCode, CORE_DOCTOR_EXIT_CODES.diagnosticFailure); + assert.match(result.stdout, /owner-only permissions cannot be verified on this platform; file was not read/); + assert.doesNotMatch(result.stdout, /file is present with private permissions/); + assert.deepEqual(requests.map(({ url }) => url.pathname), ["/healthz"]); + assert.equal(requests[0].headers.has("authorization"), false); + assert.doesNotMatch(result.stdout, new RegExp(fixtureToken)); +}); + test("loads Vite-style proxy dotenv configuration without exposing unrelated values", async (t) => { const state = await createLocalState(t); const { fetchImpl, requests } = await successfulFetchRecorder(); @@ -403,7 +611,9 @@ test("never includes credential, response, URL suffix, provider, or private-path const { fetchImpl } = await successfulFetchRecorder({ apiBody: JSON.stringify({ object: "list", - data: [{ id: "agent_fixture", name: "session-private-marker" }], + data: [canonicalAgent({ name: "session-private-marker" })], + first_id: "agent_fixture", + last_id: "agent_fixture", has_more: false, }), }); diff --git a/scripts/fixtures/core-doctor/agents-list.json b/scripts/fixtures/core-doctor/agents-list.json index 5a128ac..8435ede 100644 --- a/scripts/fixtures/core-doctor/agents-list.json +++ b/scripts/fixtures/core-doctor/agents-list.json @@ -1,4 +1,5 @@ { + "object": "list", "data": [], "first_id": null, "last_id": null, From 46e4672abd0038ccf5b676088a24adfeeb410631 Mon Sep 17 00:00:00 2001 From: sam2tom Date: Wed, 16 Sep 2026 10:58:29 +0800 Subject: [PATCH 4/4] fix: validate saved MCP tool envelope --- scripts/core-doctor.mjs | 52 ++++++++++++++++++ scripts/core-doctor.test.mjs | 101 +++++++++++++++++++++++++++++++---- 2 files changed, 143 insertions(+), 10 deletions(-) diff --git a/scripts/core-doctor.mjs b/scripts/core-doctor.mjs index 63ee0cd..488d850 100644 --- a/scripts/core-doctor.mjs +++ b/scripts/core-doctor.mjs @@ -457,6 +457,57 @@ function isStringRecord(value) { return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string"); } +function isCredentialFreeHTTPUrl(value) { + if ( + !isNonEmptyString(value) || + value.trim() !== value || + value.includes("?") || + value.includes("#") + ) { + return false; + } + try { + const url = new URL(value); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + Boolean(url.hostname) && + !url.username && + !url.password && + !url.search && + !url.hash + ); + } catch { + return false; + } +} + +function isCanonicalSavedMCPTool(value) { + const transport = value.transport; + return ( + hasOwn(value, "server_label") && + isNonEmptyString(value.server_label) && + value.server_label.trim() !== "" && + hasOwn(value, "transport") && + isRecord(transport) && + transport.type === "http" && + hasOwn(transport, "server_url") && + isCredentialFreeHTTPUrl(transport.server_url) && + (!hasOwn(transport, "headers") || + (isStringRecord(transport.headers) && Object.keys(transport.headers).length === 0)) && + hasOwn(value, "allowed_tools") && + (value.allowed_tools === null || + (Array.isArray(value.allowed_tools) && value.allowed_tools.every(isNonEmptyString))) && + value.connection_origin === "service" && + hasOwn(value, "credential_id") && + (value.credential_id === null || isNonEmptyString(value.credential_id)) && + hasOwn(value, "request_metadata") && + isRecord(value.request_metadata) && + Object.keys(value.request_metadata).length === 0 && + hasOwn(value, "required") && + value.required === false + ); +} + function isCanonicalMultiAgent(value) { if ( !isRecord(value) || @@ -525,6 +576,7 @@ function isBasicSavedAgentToolEnvelope(value) { typeof value.defer_loading === "boolean" ); } + if (value.type === "mcp") return isCanonicalSavedMCPTool(value); return true; } diff --git a/scripts/core-doctor.test.mjs b/scripts/core-doctor.test.mjs index d4aeae9..71a5a80 100644 --- a/scripts/core-doctor.test.mjs +++ b/scripts/core-doctor.test.mjs @@ -90,6 +90,20 @@ function canonicalAgent(overrides = {}) { }; } +function canonicalMCPTool(overrides = {}) { + return { + type: "mcp", + server_label: "records", + transport: { type: "http", server_url: "https://mcp.fixture.invalid/tools", headers: {} }, + allowed_tools: null, + connection_origin: "service", + credential_id: null, + request_metadata: {}, + required: false, + ...overrides, + }; +} + function canonicalAgentPage(agent = canonicalAgent(), overrides = {}) { const cursor = agent && typeof agent === "object" ? agent.id : null; return { @@ -379,16 +393,19 @@ test("accepts a canonical non-empty page with additive and unknown tool variants { type: "function", name: "", description: "", parameters: {}, defer_loading: false }, { type: "tool_search", additive_nested: true }, { type: "programmatic_tool_calling", enabled: true }, - { - type: "mcp", - server_label: "records", - transport: { type: "http", server_url: "https://mcp.fixture.invalid/tools", headers: {} }, - allowed_tools: null, - connection_origin: "service", - credential_id: null, - request_metadata: {}, - required: false, - }, + canonicalMCPTool({ + transport: { + type: "http", + server_url: "https://mcp.fixture.invalid/tools", + headers: {}, + additive_nested: true, + }, + additive_nested: true, + }), + canonicalMCPTool({ + server_label: "records-without-headers", + transport: { type: "http", server_url: "https://mcp.fixture.invalid/no-headers" }, + }), { type: "future_tool", additive_nested: true }, ], additive_agent: true, @@ -446,6 +463,70 @@ test("rejects malformed or non-canonical Agents list pages", async (t) => { ["wrong programmatic tool field type", canonicalAgentPage(canonicalAgent({ tools: [{ type: "programmatic_tool_calling", enabled: "true" }], }))], + ["incomplete MCP tool", canonicalAgentPage(canonicalAgent({ + tools: [{ type: "mcp" }], + }))], + ["empty MCP server label", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ server_label: "" })], + }))], + ["whitespace-only MCP server label", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ server_label: " " })], + }))], + ["wrong MCP transport type", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ + transport: { type: "stdio", server_url: "https://mcp.fixture.invalid/tools", headers: {} }, + })], + }))], + ["credential-bearing MCP server URL", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ + transport: { type: "http", server_url: "https://user@mcp.fixture.invalid/tools", headers: {} }, + })], + }))], + ["MCP server URL with an empty query", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ + transport: { type: "http", server_url: "https://mcp.fixture.invalid/tools?", headers: {} }, + })], + }))], + ["MCP server URL with surrounding whitespace", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ + transport: { type: "http", server_url: " https://mcp.fixture.invalid/tools", headers: {} }, + })], + }))], + ["wrong MCP header field type", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ + transport: { type: "http", server_url: "https://mcp.fixture.invalid/tools", headers: [] }, + })], + }))], + ["nonempty saved MCP headers", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ + transport: { + type: "http", + server_url: "https://mcp.fixture.invalid/tools", + headers: { authorization: "secret-marker" }, + }, + })], + }))], + ["wrong MCP allow-list item", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ allowed_tools: [""] })], + }))], + ["wrong MCP connection origin", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ connection_origin: "environment" })], + }))], + ["wrong MCP credential field type", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ credential_id: 7 })], + }))], + ["wrong MCP request metadata type", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ request_metadata: [] })], + }))], + ["nonempty saved MCP request metadata", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ request_metadata: { private: "marker" } })], + }))], + ["wrong MCP required field type", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ required: "false" })], + }))], + ["unsupported required MCP server", canonicalAgentPage(canonicalAgent({ + tools: [canonicalMCPTool({ required: true })], + }))], ["inconsistent disabled multi-agent maximum", canonicalAgentPage(canonicalAgent({ multi_agent: { enabled: false, max_concurrent_subagents: 4 }, }))],