**Verification is a tri-state now, because two states could not be right.** Round 12 was correct that `ok: true` for a manifest whose unread 513th file may be tampered with is a false pass. Round 15 is correct that forcing `ok: false` calls a server nonconformant for exceeding limits SEP-2640 states as SHOULD NOT, with hosts free to support more — contradicting this module's own rule that a warning never fails a report. Both hold, so: verified -> exit 0 everything checked, everything passed failed -> exit 7 a MUST was broken incomplete -> exit 8 nothing checked was wrong; the walk was cut short `ok` keeps its narrow meaning, `allSkillsVerified` is stricter than `every(r => r.ok)` since it selects the exit code, and `anySkillFailed` separates 7 from 8. A job that tolerates oversized catalogs can allow 8 and still fail on 7. **`manifestKey` still used `JSON.stringify` on the entry** — the same crash I fixed in the TUI in round 12, one file over. **Directory children were navigable outside the skill root.** A server could return `skill://other-skill/...` and clicking it left the tree, with "Up" only comparing equality against the root. Containment is checked on the normalized URI now, and an outside child renders as text rather than a link. Worth recording from the tests: `..` cannot escape the authority, so `skill://a/nested/../x.md` resolves back inside and must stay navigable. **The TUI matched report rows by raw URI**, which missed a row recorded under an equivalent spelling while `extraReportFiles` suppressed it as covered. Normalized, like the membership test beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc Signed-off-by: cliffhall <cliff@futurescale.com>
261 lines
9 KiB
TypeScript
261 lines
9 KiB
TypeScript
import { awaitableError } from "./utils/awaitable-log.js";
|
|
|
|
/**
|
|
* Exit-code map. Non-zero codes let an automated caller (CI, an agent) branch
|
|
* on the failure class without regex-scraping stderr:
|
|
*
|
|
* - 0: success
|
|
* - 1: usage / unexpected error (the catch-all; same as before this map)
|
|
* - 2: `--app-info` probe found no MCP App on the tool
|
|
* - 3: server requires authentication (401 / WWW-Authenticate / OAuth)
|
|
* - 4: server unreachable (DNS, connect refused, timeout, fetch failure)
|
|
* - 5: tool error (`tools/call` returned `isError:true`, or tool not found)
|
|
* - 6: `--strict` found an error-severity tool-schema portability finding
|
|
*
|
|
* Note 6 is `SCHEMA_UNPORTABLE`, not "invalid": the whole premise of the lint
|
|
* is that these schemas ARE valid JSON Schema and are merely refused by some
|
|
* clients. Calling the outcome "invalid" would misreport it to the automated
|
|
* callers this map exists for.
|
|
*/
|
|
export const EXIT_CODES = {
|
|
OK: 0,
|
|
USAGE: 1,
|
|
NO_APP: 2,
|
|
AUTH_REQUIRED: 3,
|
|
UNREACHABLE: 4,
|
|
TOOL_ERROR: 5,
|
|
SCHEMA_UNPORTABLE: 6,
|
|
/**
|
|
* `--verify` found a SEP-2640 violation: a conformance error, a digest or
|
|
* size mismatch, or a manifest file that could not be read (#2248).
|
|
*
|
|
* Its own code rather than reusing `SCHEMA_UNPORTABLE`, for the reason that
|
|
* one exists at all: a CI job that fails on an unportable tool schema and a
|
|
* CI job that fails on a tampered skill digest are different jobs, and
|
|
* collapsing them would make `if [ $? -eq 6 ]` ambiguous.
|
|
*/
|
|
SKILL_NONCONFORMANT: 7,
|
|
/**
|
|
* `--verify` could not check the whole catalog: the read bounds stopped the
|
|
* walk before it finished (#2248).
|
|
*
|
|
* Distinct from `SKILL_NONCONFORMANT` because the server has broken no
|
|
* **MUST** — SEP-2640 states the 512-entry and 16 MiB limits as SHOULD NOT,
|
|
* with hosts free to support more — so exiting 7 would call a conforming
|
|
* server nonconformant. It is still non-zero, because reporting success for
|
|
* a manifest whose unread entries were never fetched is a false pass. A CI
|
|
* job that wants to tolerate oversized catalogs can allow 8 and still fail
|
|
* on 7.
|
|
*/
|
|
SKILL_INCOMPLETE: 8,
|
|
} as const;
|
|
|
|
/** Machine-readable error envelope written as one JSON line on stderr. */
|
|
export interface ErrorEnvelope {
|
|
/** Stable identifier for the failure class (e.g. "auth_required"). */
|
|
code: string;
|
|
/** Human-readable message. */
|
|
message: string;
|
|
/** The underlying error's `cause` (e.g. undici's ENOTFOUND), if any. */
|
|
cause?: string;
|
|
/** HTTP status when the failure was an HTTP-level response. */
|
|
status?: number;
|
|
/** The server URL the failure was against, when known. */
|
|
url?: string;
|
|
}
|
|
|
|
/**
|
|
* Thrown by the CLI to request a specific non-zero exit code without routing
|
|
* through the generic error path. {@link formatErrorOutput} reads `exitCode`
|
|
* and `envelope`; the in-process test runner does the same so tests observe
|
|
* the real code and stderr.
|
|
*/
|
|
export class CliExitCodeError extends Error {
|
|
constructor(
|
|
public readonly exitCode: number,
|
|
message: string,
|
|
public readonly envelope?: Partial<ErrorEnvelope>,
|
|
) {
|
|
super(message);
|
|
this.name = "CliExitCodeError";
|
|
}
|
|
}
|
|
|
|
/** Depth cap for {@link causeOf} — bounds a pathological/self-referential
|
|
* `error.cause` chain so a cycle can never recurse infinitely. */
|
|
const MAX_CAUSE_DEPTH = 16;
|
|
|
|
/** Read an `Error.cause` chain into a single readable string, if present. */
|
|
function causeOf(error: unknown, depth = 0): string | undefined {
|
|
if (depth >= MAX_CAUSE_DEPTH) return undefined;
|
|
if (!(error instanceof Error)) return undefined;
|
|
const cause = (error as { cause?: unknown }).cause;
|
|
if (cause === undefined || cause === null) return undefined;
|
|
if (cause instanceof Error) {
|
|
const nested = causeOf(cause, depth + 1);
|
|
return nested ? `${cause.message}: ${nested}` : cause.message;
|
|
}
|
|
return String(cause);
|
|
}
|
|
|
|
/** Best-effort HTTP status from common error shapes (undici, MCP SDK, fetch). */
|
|
function statusOf(error: unknown): number | undefined {
|
|
if (error == null || typeof error !== "object") return undefined;
|
|
const e = error as {
|
|
status?: unknown;
|
|
code?: unknown;
|
|
response?: { status?: unknown };
|
|
};
|
|
if (typeof e.status === "number") return e.status;
|
|
if (typeof e.response?.status === "number") return e.response.status;
|
|
// SDK SseError / StreamableHTTPError expose `.code` as the HTTP status. Guard
|
|
// to the HTTP range (100-599) so a JSON-RPC ProtocolError code (e.g. -32601
|
|
// MethodNotFound) is not mistaken for an HTTP status and leaked into the
|
|
// envelope or misclassified as AUTH_REQUIRED. String node codes like
|
|
// "ENOTFOUND" are already excluded by the numeric check.
|
|
if (typeof e.code === "number" && e.code >= 100 && e.code <= 599) {
|
|
return e.code;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const UNREACHABLE_PATTERN =
|
|
/ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|ETIMEDOUT|fetch failed|getaddrinfo|connect timed out|aborted/i;
|
|
|
|
/**
|
|
* Classify an arbitrary error into an exit code and envelope. Used both by the
|
|
* binary's {@link handleError} and by callers that want to throw a
|
|
* {@link CliExitCodeError} with the right code up front.
|
|
*/
|
|
export function classifyError(
|
|
error: unknown,
|
|
context?: { url?: string },
|
|
): { exitCode: number; envelope: ErrorEnvelope } {
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: typeof error === "string"
|
|
? error
|
|
: "Unknown error";
|
|
const cause = causeOf(error);
|
|
const status = statusOf(error);
|
|
const url = context?.url;
|
|
|
|
// A pre-classified error carries its own exit code; fill in any envelope
|
|
// fields the thrower didn't supply.
|
|
if (error instanceof CliExitCodeError) {
|
|
return {
|
|
exitCode: error.exitCode,
|
|
envelope: {
|
|
code: error.envelope?.code ?? codeForExit(error.exitCode),
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(error.envelope?.status !== undefined && {
|
|
status: error.envelope.status,
|
|
}),
|
|
...((error.envelope?.url ?? url) !== undefined && {
|
|
url: error.envelope?.url ?? url,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
// 401 / OAuth-required → AUTH_REQUIRED so the caller can kick the auth flow.
|
|
if (
|
|
status === 401 ||
|
|
status === 403 ||
|
|
/WWW-Authenticate|Unauthorized|invalid_token|OAuth/i.test(
|
|
message + " " + (cause ?? ""),
|
|
)
|
|
) {
|
|
return {
|
|
exitCode: EXIT_CODES.AUTH_REQUIRED,
|
|
envelope: {
|
|
code: "auth_required",
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(status !== undefined && { status }),
|
|
...(url !== undefined && { url }),
|
|
},
|
|
};
|
|
}
|
|
|
|
// Network-layer failure → UNREACHABLE so the caller can retry via a proxy.
|
|
if (UNREACHABLE_PATTERN.test(message + " " + (cause ?? ""))) {
|
|
return {
|
|
exitCode: EXIT_CODES.UNREACHABLE,
|
|
envelope: {
|
|
code: "unreachable",
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(status !== undefined && { status }),
|
|
...(url !== undefined && { url }),
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
exitCode: EXIT_CODES.USAGE,
|
|
envelope: {
|
|
code: "error",
|
|
message,
|
|
...(cause !== undefined && { cause }),
|
|
...(status !== undefined && { status }),
|
|
...(url !== undefined && { url }),
|
|
},
|
|
};
|
|
}
|
|
|
|
function codeForExit(exitCode: number): string {
|
|
switch (exitCode) {
|
|
case EXIT_CODES.NO_APP:
|
|
return "no_app";
|
|
case EXIT_CODES.AUTH_REQUIRED:
|
|
return "auth_required";
|
|
case EXIT_CODES.UNREACHABLE:
|
|
return "unreachable";
|
|
case EXIT_CODES.TOOL_ERROR:
|
|
return "tool_error";
|
|
case EXIT_CODES.SCHEMA_UNPORTABLE:
|
|
return "schema_unportable";
|
|
default:
|
|
return "error";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single source of truth for mapping a thrown error to the CLI's exit code and
|
|
* stderr text. The binary entry ({@link handleError}) and the in-process test
|
|
* runner both call this, so tests observe exactly what the binary would emit.
|
|
*
|
|
* stderr is one JSON line `{"error":{...}}` so a caller can `2>&1 | tail -1 |
|
|
* jq .error`; the message is inside the envelope so nothing is lost vs. the
|
|
* previous bare-message behaviour.
|
|
*/
|
|
export function formatErrorOutput(
|
|
error: unknown,
|
|
context?: { url?: string },
|
|
): { exitCode: number; stderr: string } {
|
|
const { exitCode, envelope } = classifyError(error, context);
|
|
return {
|
|
exitCode,
|
|
stderr: JSON.stringify({ error: envelope }) + "\n",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The binary's last-resort error sink: write the envelope, then exit.
|
|
*
|
|
* **Async, and the await is load-bearing.** On a pipe or a file — as opposed
|
|
* to a TTY — `process.stderr.write` is asynchronous, and `process.exit()`
|
|
* discards whatever is still queued. Size is not the safeguard it looks like:
|
|
* the envelope being small enough for the pipe buffer says nothing about
|
|
* whether the write has been *performed* by the time the process goes away.
|
|
* So this settles on the write callback first, exactly as the `--strict`
|
|
* schema report does.
|
|
*/
|
|
export async function handleError(error: unknown): Promise<never> {
|
|
const { exitCode, stderr } = formatErrorOutput(error);
|
|
await awaitableError(stderr);
|
|
process.exit(exitCode);
|
|
}
|