diff --git a/docs/cli.md b/docs/cli.md index d1922b8a..64dbd24b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -191,6 +191,19 @@ route for it. - Browser/host URL: use `hack open --json`; OAuth aliases are preferred when enabled. - Container-to-container traffic: use Compose DNS rather than routing back through Caddy. +For `run`, `exec`, `host exec`, `env exec`, `x` and the Beta `dispatch run`, Hack options go before +the child command. +From the child command's first word on, every argument belongs to the child, including flags +that Hack also defines: `hack run web bun --version` runs `bun --version` in the container +rather than printing Hack's version, and a child `--json` does not switch Hack's own error output +to JSON. A `--` before the child command is optional and is not passed on; a later `--` (as in +`hack run web npm test -- --watch`) reaches the child unchanged. + +`session exec` takes the workspace command as one argument, so quote a command that has its own +arguments or flags: `hack session exec ws "npm --version"`. Hack options go before it, and an +unquoted `hack session exec ws npm --version` refuses with a hint to quote the command instead +of printing Hack's version. + Service-scoped runtime changes do not run project-wide lifecycle hooks and do not start Compose dependencies implicitly, except declared shared-cache installers needed by the selected services: diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 6ab6d4a4..50a6c6fb 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -663,9 +663,25 @@ lifecycle hooks, and publishes the run mapping after native readiness. Ctrl-C wa for owned cleanup and keeps the stopped run mapping and named volumes after confirming cleanup. The VM remains available until explicit runtime shutdown. +Without that grant, `up` and `restart` refuse with `E_PERMISSION` before any stop or +runtime request; set it and run the command again. + This initial foreground path does not support detached/JSON startup, selected -services or external networks. Routed services and host dependencies require the -explicit native selections described below. Existing +services or external networks. `--detach`, `--json`, `--target` and service names +each refuse with a one-line `E_USAGE` message that names the option and the supported +form: run `hack up` in the foreground in its own terminal and use `hack ps` and +`hack logs` from another. Routed services and host dependencies require the +explicit native selections described below. Unsupported Compose or environment input +(for example a service `env_file`, or a project `.env` without managed env) and +unreviewed route labels refuse with `E_NATIVE_PROJECT_UNSUPPORTED`; invalid branch +hosts, project adaptations, AWS selections and changed fresh-exec environments refuse +with `E_CONFIG_INVALID`, as do an unbounded `--profile` selection, a malformed +`HACK_NATIVE_DEPENDENCIES` listener selection and an occupied HTTPS port. A runtime +reply, saved mapping or retained graph that changed or cannot be confirmed (for example +a startup receipt for another run, or a mapping owned by another run) is an integrity +or ownership refusal, not a configuration one: it prints one closed line naming what +was not confirmed, and `--json` reports it as `E_UNEXPECTED`. Each prints one closed +line and drops nothing. Existing `hack.dependencies.*` cache declarations publish the reviewed source snapshot and require successful initializer completion before dependent services start. Cache initializers receive immutable source mounts and writable named cache volumes; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 533ea57d..58828fa3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -260,9 +260,11 @@ Run a one-off command in a service container (docker compose run --rm) ### Usage ```bash -hack run [cmd...] [options] +hack run [options] [cmd...] ``` +Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`. Put Hack options before it; a leading `--` is optional. + ### Arguments | Arg | Description | @@ -292,9 +294,11 @@ Run a command in an already-running service container (docker compose exec) ### Usage ```bash -hack exec [cmd...] [options] +hack exec [options] [cmd...] ``` +Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`. Put Hack options before it; a leading `--` is optional. + ### Arguments | Arg | Description | @@ -841,9 +845,11 @@ Send a command to a running workspace ### Usage ```bash -hack session exec [options] +hack session exec [options] ``` +`` is one argument: quote a command that has its own arguments or flags, as in `"npm --version"`. Put Hack options before it; a leading `--` is optional. + Queue a command in the workspace without opening a new interactive attach flow. Tmux sends it to the active pane; zellij opens a new pane for the command. This is useful for long-running agents, background checks, or remote follow-up work. ### Arguments @@ -2478,9 +2484,11 @@ Run a host command with project env injected ### Usage ```bash -hack env exec [command...] [options] +hack env exec [options] [command...] ``` +Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`. Put Hack options before it; a leading `--` is optional. + Inject the selected Hack env overlay directly into a one-off host command without materializing .hack/.env. To inspect a value, prefer `printenv KEY` or `sh -lc 'printf "%s\n" "$KEY"'`; `echo $KEY` expands in your current shell before Hack injects env. ### Arguments @@ -2640,9 +2648,11 @@ Run a host command with project env injected ### Usage ```bash -hack host exec [command...] [options] +hack host exec [options] [command...] ``` +Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`. Put Hack options before it; a leading `--` is optional. + Run a one-off command on the host with the selected Hack env overlay injected. Use --scope when you want service-scoped values without running inside that service container. To inspect a value, prefer `printenv KEY` or `sh -lc 'printf "%s\n" "$KEY"'`; `echo $KEY` expands in your current shell before Hack injects env. ### Arguments @@ -2702,9 +2712,11 @@ Run extension commands ### Usage ```bash -hack x [args...] [options] +hack x [options] [args...] ``` +Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`. Put Hack options before it; a leading `--` is optional. + Usage: hack x list hack x help diff --git a/docs/reference/native-authored-commands.md b/docs/reference/native-authored-commands.md index 8c42abcd..7125c388 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -111,6 +111,21 @@ Ctrl-C requests that shutdown. Exact durable Removed evidence retires the tagged native run and startup intent. Unknown, changed or live cleanup retains the attempt and refuses replay; a missing ready mapping does not authorize a fresh start. +On macOS, `up --detach` and `restart --detach` refuse with `E_USAGE` and the same +one-line message as an image project: native `up` runs in the foreground only, so run +it without `--detach` in its own terminal, use `ps` and `logs` from another terminal, +and press Ctrl-C to stop it. + +After native review and before it reserves the startup intent, `up` asks the runtime +for its read-only status. A stopped runtime (any phase other than `running`, or a dead +runtime process) refuses at stage `reservation` with native code `runtime_not_running`. +The CLI reports `E_DOCKER_UNAVAILABLE`: "Native runtime is not running; nothing was +started." No intent is retained, so `ps` keeps reporting `not_started`. An inconclusive +status reply does not refuse. The check is advisory: if the runtime stops after it, +the start fails later and, when its intent was already reserved, keeps that evidence; +the CLI then still reports `E_DOCKER_UNAVAILABLE`, and `ps` reports `pending` until the +retained attempt is inspected. + ## Saved-run `ps` `ps` and `ps --json` issue one authenticated current status request for the exact @@ -122,10 +137,11 @@ Only fresh runtime observations supply state and health. Stored readiness and ph do not prove that a service is still live. A project with no saved attempt reports `not_started`; a retained startup intent -without a Ready mapping reports `pending` with no confirmed service rows. That -state covers a startup in progress and a retained incomplete attempt. When no -startup is running, `down --recover` can retire the attempt if it never reserved -runtime state (see below). When a +without a Ready mapping reports `pending` with no confirmed service rows. `pending` +covers a start that is still in progress and a failed start whose reserved intent was +retained as evidence; Hack does not guess which, because only the owner's cleanup proof +may retire that intent. When no startup is running, `down --recover` can retire the +attempt if it never reserved runtime state (see below). When a Ready generation is saved but the candidate runtime is stopped (native code `runtime_not_running`, or the authenticated `native_graph_foreground` control code with that exact cause), `ps` exits 0 and reports `runtime_not_running` with the @@ -272,7 +288,8 @@ half is `up`, `host.up.before`, `host.up.after` and `host.processes` run again exactly as on `up`, after the stopped generation's `host.down.before`, `host.down.after` and process stop completed. Startup refusals keep the `up` contract: a fixed owning stage, a closed compiler or native code and -`E_STARTUP_INCOMPLETE` or `E_NATIVE_PROJECT_UNSUPPORTED`. Retained startup evidence +`E_STARTUP_INCOMPLETE`, `E_NATIVE_PROJECT_UNSUPPORTED` or, for a stopped runtime, +`E_DOCKER_UNAVAILABLE`. Retained startup evidence from an incomplete retirement refuses the fresh start without replay. A Ctrl-C that arrives after the proven stop and before the fresh start returns 130 with nothing restarted. Each half uses the configured `HACK_COMPOSE_STARTUP_TIMEOUT_MS` budget diff --git a/scripts/check-native-acquisition-plan-cli.ts b/scripts/check-native-acquisition-plan-cli.ts index 1dd8bbc5..cdf73353 100644 --- a/scripts/check-native-acquisition-plan-cli.ts +++ b/scripts/check-native-acquisition-plan-cli.ts @@ -270,9 +270,13 @@ try { assert( runtime.exit !== 0 && (runtime.stdout + runtime.stderr).includes( - "E_NATIVE_PROJECT_UNSUPPORTED" + // On macOS authored foreground up is supported, so --detach gets the + // shared one-line E_USAGE refusal; elsewhere it is unsupported. + process.platform === "darwin" + ? "E_USAGE" + : "E_NATIVE_PROJECT_UNSUPPORTED" ), - "unsupported native detached up refuses before input or runtime work" + "native detached up refuses before input or runtime work" ); assert(!(await Bun.file(marker).exists()), "planning executes no hook"); for (const cwd of [primary, checkout]) { diff --git a/scripts/check-native-endpoint-plan-cli.ts b/scripts/check-native-endpoint-plan-cli.ts index 6432965d..1b5068e4 100644 --- a/scripts/check-native-endpoint-plan-cli.ts +++ b/scripts/check-native-endpoint-plan-cli.ts @@ -376,9 +376,13 @@ try { assert( runtime.exit !== 0 && (runtime.stdout + runtime.stderr).includes( - "E_NATIVE_PROJECT_UNSUPPORTED" + // On macOS authored foreground up is supported, so --detach gets the + // shared one-line E_USAGE refusal; elsewhere it is unsupported. + process.platform === "darwin" + ? "E_USAGE" + : "E_NATIVE_PROJECT_UNSUPPORTED" ), - "unsupported native detached up refuses before input or runtime work" + "native detached up refuses before input or runtime work" ); assert( !(await Bun.file(marker).exists()), diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index d1789f99..89a51ade 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -254,8 +254,10 @@ try { }); require(unsupported.exit !== 0 && (unsupported.stdout + unsupported.stderr).includes( - "E_NATIVE_PROJECT_UNSUPPORTED" - ), "unsupported native detached up refuses before input or runtime work"); + // On macOS authored foreground up is supported, so --detach gets the + // shared one-line E_USAGE refusal; elsewhere it is unsupported. + process.platform === "darwin" ? "E_USAGE" : "E_NATIVE_PROJECT_UNSUPPORTED" + ), "native detached up refuses before input or runtime work"); require((await readdir(home)).length === 0 && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" diff --git a/scripts/check-native-host-plan-cli.ts b/scripts/check-native-host-plan-cli.ts index 75ea1d3f..1b594cad 100644 --- a/scripts/check-native-host-plan-cli.ts +++ b/scripts/check-native-host-plan-cli.ts @@ -283,9 +283,13 @@ try { assert( runtime.exit !== 0 && (runtime.stdout + runtime.stderr).includes( - "E_NATIVE_PROJECT_UNSUPPORTED" + // On macOS authored foreground up is supported, so --detach gets the + // shared one-line E_USAGE refusal; elsewhere it is unsupported. + process.platform === "darwin" + ? "E_USAGE" + : "E_NATIVE_PROJECT_UNSUPPORTED" ), - "unsupported native detached up refuses before input or runtime work" + "native detached up refuses before input or runtime work" ); assert( !(await Bun.file(marker).exists()) && diff --git a/scripts/check-native-process-plan-cli.ts b/scripts/check-native-process-plan-cli.ts index 1cdcddeb..7d0f3aae 100644 --- a/scripts/check-native-process-plan-cli.ts +++ b/scripts/check-native-process-plan-cli.ts @@ -271,9 +271,13 @@ try { assert( runtime.exit !== 0 && (runtime.stdout + runtime.stderr).includes( - "E_NATIVE_PROJECT_UNSUPPORTED" + // On macOS authored foreground up is supported, so --detach gets the + // shared one-line E_USAGE refusal; elsewhere it is unsupported. + process.platform === "darwin" + ? "E_USAGE" + : "E_NATIVE_PROJECT_UNSUPPORTED" ), - "unsupported native detached up refuses before input or runtime work" + "native detached up refuses before input or runtime work" ); assert( !(await Bun.file(marker).exists()), diff --git a/scripts/check-native-routing-plan-cli.ts b/scripts/check-native-routing-plan-cli.ts index 00d2fbc5..eaeb9bf5 100644 --- a/scripts/check-native-routing-plan-cli.ts +++ b/scripts/check-native-routing-plan-cli.ts @@ -384,9 +384,13 @@ try { assert( runtime.exit !== 0 && (runtime.stdout + runtime.stderr).includes( - "E_NATIVE_PROJECT_UNSUPPORTED" + // On macOS authored foreground up is supported, so --detach gets the + // shared one-line E_USAGE refusal; elsewhere it is unsupported. + process.platform === "darwin" + ? "E_USAGE" + : "E_NATIVE_PROJECT_UNSUPPORTED" ), - "unsupported native detached up refuses before input or runtime work" + "native detached up refuses before input or runtime work" ); assert( !( diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index b2b28a94..e9d0d667 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -67,10 +67,13 @@ import { NativeRuntimeRequestError, type NativeRuntimeSelection, } from "./native-runtime-client.ts"; +import { refuseStoppedNativeRuntime } from "./native-runtime-preflight.ts"; const RUN = /^[a-f0-9]{32}$/; const CODE = /^[a-z][a-z0-9_]{0,63}$/; const PRIVATE_LIMIT = 256 * 1024; +/** Upper bound of the read-only runtime check before the startup intent. */ +const RUNTIME_STATUS_TIMEOUT_MS = 5000; type Inputs = Awaited>; /** * `unreserved`: the native consumer settled after refusing before it reserved @@ -119,6 +122,11 @@ export class NativeAuthoredProjectStartError extends Error { readonly outcome: Outcome; readonly canceled: boolean; readonly nativeCode?: string; + /** + * Bounded cause allowed with `nativeCode`, for example `runtime_not_running` + * wrapped by authored foreground control in `native_graph_foreground`. + */ + readonly nativeCauseCode?: string; readonly stage: Stage; readonly compilerCode?: string; @@ -126,6 +134,7 @@ export class NativeAuthoredProjectStartError extends Error { readonly outcome: Outcome; readonly canceled: boolean; readonly nativeCode?: string; + readonly nativeCauseCode?: string; readonly stage?: Stage; readonly compilerCode?: string; }) { @@ -154,6 +163,10 @@ export class NativeAuthoredProjectStartError extends Error { this.outcome = opts.outcome; this.canceled = opts.canceled; this.nativeCode = nativeCode; + this.nativeCauseCode = + nativeCode && opts.nativeCauseCode && CODE.test(opts.nativeCauseCode) + ? opts.nativeCauseCode + : undefined; this.stage = stage; this.compilerCode = compilerCode; } @@ -580,7 +593,9 @@ async function runPreparedLifecycle(ctx: { readonly removed: () => void; readonly unreserved: () => void; readonly hookRetired: () => void; - readonly nativeCode: (code: string | undefined) => void; + readonly nativeCode: ( + diagnostic: Pick + ) => void; }): Promise { const review = ctx.review; let downBefore = false; @@ -642,7 +657,7 @@ async function runPreparedLifecycle(ctx: { ctx.completeStop(false); }, onExitDiagnostic: (diagnostic) => { - ctx.nativeCode(diagnostic.nativeCode); + ctx.nativeCode(diagnostic); const observed: unknown = ctx.options.onExitDiagnostic?.(diagnostic); void Promise.resolve(observed).catch(() => undefined); }, @@ -758,10 +773,12 @@ async function failedAdmission(opts: { readonly attempt: Attempt | undefined; readonly hookOwner: NativeAuthoredHookOwner | undefined; readonly nativeCode: string | undefined; + readonly nativeCauseCode: string | undefined; }): Promise { const { error, source, attempt, hookOwner } = opts; let outcome = await removeUnstartedSource(source, attempt, opts.outcome); let nativeCode = opts.nativeCode; + let nativeCauseCode = opts.nativeCauseCode; if (hookOwner && !attempt) { try { await hookOwner.retire(); @@ -775,7 +792,10 @@ async function failedAdmission(opts: { // A follow-up retirement inspection may refuse because startup never // published a receipt. Preserve the already captured native startup code; // inspection still refuses retirement and retains every recovery binding. - nativeCode ??= error.nativeCode; + if (nativeCode === undefined) { + nativeCode = error.nativeCode; + nativeCauseCode = error.nativeCauseCode; + } } else if ( outcome === "not-started" && source === undefined && @@ -785,11 +805,13 @@ async function failedAdmission(opts: { error instanceof NativeComposeHostHookError) ) { nativeCode = "native_graph_subset"; + nativeCauseCode = undefined; } return new NativeAuthoredProjectStartError({ outcome, canceled: opts.options.signal?.aborted === true, nativeCode, + nativeCauseCode, stage: opts.stage, compilerCode: compilerDiagnostic(error), }); @@ -920,6 +942,7 @@ export async function serveNativeAuthoredProject( let payload: Buffer | undefined; let outcome: Outcome = "not-started"; let nativeCode: string | undefined; + let nativeCauseCode: string | undefined; let hookOwner: NativeAuthoredHookOwner | undefined; let hostProcesses: NativeAuthoredHostProcesses | undefined; let storageTool: NativeAuthoredStorageTool | undefined; @@ -1082,6 +1105,18 @@ export async function serveNativeAuthoredProject( await source.assertFresh(); remaining(); stage = "reservation"; + // A stopped runtime refuses here, before the startup intent exists, + // so the failed start retains nothing and ps stays not_started. + await refuseStoppedNativeRuntime({ + runtime: opts.runtime, + cwd: opts.scope.projectRoot, + timeoutMs: Math.min(RUNTIME_STATUS_TIMEOUT_MS, remaining()), + signal: controller.signal, + }); + // The check treats a failed request as inconclusive, including one + // ended by cancellation; recheck so a cancel or deadline during it + // still refuses before the startup intent is reserved. + remaining(); attempt = { source, start: await admission.reserve({ review }) }; outcome = "retained"; stage = "runtime"; @@ -1135,7 +1170,8 @@ export async function serveNativeAuthoredProject( hookOwner = undefined; }, nativeCode: (value) => { - nativeCode = value; + nativeCode = value.nativeCode; + nativeCauseCode = value.nativeCauseCode; }, }); return { ok: true as const, code }; @@ -1157,6 +1193,7 @@ export async function serveNativeAuthoredProject( attempt, outcome, nativeCode, + nativeCauseCode, hookOwner, }); outcome = failure.outcome; diff --git a/src/backends/native-aws-environment.ts b/src/backends/native-aws-environment.ts index 90ec577b..3f0804d3 100644 --- a/src/backends/native-aws-environment.ts +++ b/src/backends/native-aws-environment.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { join } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; @@ -17,9 +18,11 @@ const CREDENTIAL_KEYS = [ ]; function refused(): Error { - return new Error( - "Native AWS adaptation refused: verify the explicit profile, supported read-only AWS mount and unexpired exported credentials; values omitted." - ); + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native AWS adaptation refused: verify the explicit profile, supported read-only AWS mount and unexpired exported credentials; values omitted.", + }); } export type NativeAwsExportSource = (opts: { diff --git a/src/backends/native-exec-environment.ts b/src/backends/native-exec-environment.ts index bb294e2e..ee1f20a6 100644 --- a/src/backends/native-exec-environment.ts +++ b/src/backends/native-exec-environment.ts @@ -1,4 +1,5 @@ import { basename } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { prepareNativeProjectInput } from "./native-project-input.ts"; import type { @@ -21,9 +22,11 @@ export async function prepareNativeExecEnvironment(opts: { Object.hasOwn(opts.run, "aws") ) ) { - throw new Error( - "Native fresh exec requires recorded startup environment and AWS selections; restart with the current candidate. Values omitted." - ); + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native fresh exec requires recorded startup environment and AWS selections; restart with the current candidate. Values omitted.", + }); } try { let input = await (opts.prepare ?? prepareNativeProjectInput)({ @@ -60,8 +63,10 @@ export async function prepareNativeExecEnvironment(opts: { } return input.managedEnvironment[opts.service] ?? {}; } catch { - throw new Error( - "Native fresh exec environment is unavailable or changed; no command was requested. Values omitted." - ); + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native fresh exec environment is unavailable or changed; no command was requested. Values omitted.", + }); } } diff --git a/src/backends/native-https-owner.ts b/src/backends/native-https-owner.ts index 753bdf0e..37aa8ad7 100644 --- a/src/backends/native-https-owner.ts +++ b/src/backends/native-https-owner.ts @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto"; import { lstat, mkdir, realpath } from "node:fs/promises"; import { connect, type Socket } from "node:net"; import { dirname, join } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { acquireNativeHttpsOwnerAdmission } from "./native-https-owner-admission.ts"; import { @@ -85,7 +86,7 @@ export interface NativeHttpsAcquireOptions { /** Persist finalization ownership before any lease request can be delivered. */ readonly onIntent?: (identity: NativeHttpsLeaseIdentity) => Promise; } -export class NativeHttpsLeaseAcquisitionError extends Error { +export class NativeHttpsLeaseAcquisitionError extends ClosedCliError { readonly identity: NativeHttpsLeaseIdentity; readonly startupFailure: NativeHttpsStartupError | undefined; constructor( diff --git a/src/backends/native-https-port.ts b/src/backends/native-https-port.ts index d0c80b2c..c5a906b6 100644 --- a/src/backends/native-https-port.ts +++ b/src/backends/native-https-port.ts @@ -1,4 +1,26 @@ import { createServer } from "node:net"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; + +/** Closed, value-free refusal for a selected HTTPS port that cannot be bound. */ +function portRefusal(code: string | undefined): Error { + if (code === "EACCES" || code === "EPERM") { + return new HackCliError({ + code: "E_PERMISSION", + message: + "The selected native HTTPS port requires host permission; use an unprivileged port for testing or configure authorized host forwarding.", + }); + } + if (code === "EADDRINUSE") { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "The selected native HTTPS port is already in use; the existing listener was left untouched. Select a free port.", + }); + } + return new ClosedCliError( + "The selected native HTTPS port could not be checked; values omitted." + ); +} /** Check local bind permission without adopting or stopping an existing listener. * Caddy must still acquire the port itself; this check cannot reserve it. @@ -7,19 +29,14 @@ export async function checkNativeHttpsPort(port: number): Promise { const server = createServer(); await new Promise((resolve, reject) => { server.once("error", (error: NodeJS.ErrnoException) => { - let reason = "could not be checked"; - if (error.code === "EACCES" || error.code === "EPERM") { - reason = - "requires host permission; use an unprivileged port for testing or configure authorized host forwarding"; - } else if (error.code === "EADDRINUSE") { - reason = "is already in use; the existing listener was left untouched"; - } - reject(new Error(`Native HTTPS port ${port} ${reason}.`)); + reject(portRefusal(error.code)); }); server.listen(port, "127.0.0.1", () => { server.close((error) => { if (error) { - reject(new Error("Native HTTPS port probe could not close.")); + reject( + new ClosedCliError("Native HTTPS port probe could not close.") + ); } else { resolve(); } diff --git a/src/backends/native-https-startup-failure.ts b/src/backends/native-https-startup-failure.ts index a48edfe0..dd61ded9 100644 --- a/src/backends/native-https-startup-failure.ts +++ b/src/backends/native-https-startup-failure.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { type NativeHttpsOwnerConfiguration, @@ -61,7 +62,7 @@ function isDiagnostic(value: unknown): value is Diagnostic { ); } /** Only reviewed classifications cross the detached owner boundary; never raw errors. */ -export class NativeHttpsStartupError extends Error { +export class NativeHttpsStartupError extends ClosedCliError { readonly diagnostic: Diagnostic; constructor(stage: Stage, error?: unknown, cleanupUnconfirmed = false) { const inherited = diff --git a/src/backends/native-prepared-base.ts b/src/backends/native-prepared-base.ts index c6c2d88f..f5a7e4ae 100644 --- a/src/backends/native-prepared-base.ts +++ b/src/backends/native-prepared-base.ts @@ -1,4 +1,5 @@ import { isAbsolute } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; /** * Opt-in prepared base for a fresh native pool (`runtime up --prepared-base`). @@ -18,10 +19,15 @@ export interface NativePreparedBase { type Environment = Readonly>; +/** An invalid selection is user configuration: a closed `E_CONFIG_INVALID` refusal. */ +function invalid(message: string): HackCliError { + return new HackCliError({ code: "E_CONFIG_INVALID", message }); +} + /** * Parse `HACK_NATIVE_PREPARED_BASE` (`prefer`, `require`, or unset/`off` to disable) and the - * optional absolute `HACK_NATIVE_PREPARED_BASE_STORE`. Invalid combinations throw before any - * runtime effect. + * optional absolute `HACK_NATIVE_PREPARED_BASE_STORE`. Invalid combinations throw a closed + * `E_CONFIG_INVALID` refusal before any runtime effect. */ export function parseNativePreparedBase( env: Environment @@ -31,24 +37,20 @@ export function parseNativePreparedBase( const hasStore = store !== undefined && store !== ""; if (mode === undefined || mode === "" || mode === "off") { if (hasStore) { - throw new Error( + throw invalid( "HACK_NATIVE_PREPARED_BASE_STORE requires HACK_NATIVE_PREPARED_BASE=prefer or require." ); } return undefined; } if (mode !== "prefer" && mode !== "require") { - throw new Error( - "HACK_NATIVE_PREPARED_BASE must be prefer, require or off." - ); + throw invalid("HACK_NATIVE_PREPARED_BASE must be prefer, require or off."); } if (!hasStore) { return { mode }; } if (!isAbsolute(store)) { - throw new Error( - "HACK_NATIVE_PREPARED_BASE_STORE must be an absolute path." - ); + throw invalid("HACK_NATIVE_PREPARED_BASE_STORE must be an absolute path."); } return { mode, store }; } diff --git a/src/backends/native-project-adaptation.ts b/src/backends/native-project-adaptation.ts index a115f74f..b1bcc659 100644 --- a/src/backends/native-project-adaptation.ts +++ b/src/backends/native-project-adaptation.ts @@ -1,3 +1,4 @@ +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { addNativeHostAliases } from "./native-project-host-aliases.ts"; import type { NativeProjectInput } from "./native-project-input.ts"; @@ -25,9 +26,11 @@ const PATH = /^\/[!-~]*$/; const HOST_SEPARATOR = /[\s,]+/; function refused(): Error { - return new Error( - "Native project adaptation is invalid or conflicts with declared configuration; values omitted." - ); + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native project adaptation is invalid or conflicts with declared configuration; values omitted.", + }); } function integer(value: unknown, min: number, max: number): value is number { return ( diff --git a/src/backends/native-project-branch.ts b/src/backends/native-project-branch.ts index fc9cc15f..ce387d39 100644 --- a/src/backends/native-project-branch.ts +++ b/src/backends/native-project-branch.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { rewriteCaddyLabelForBranch } from "../lib/branch-hosts.ts"; +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { findProjectContext, @@ -14,9 +15,11 @@ const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const HOST_SEPARATOR = /[\s,]+/; function refused(): Error { - return new Error( - "Native branch routing requires a canonical branch and valid project host configuration; values omitted." - ); + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native branch routing requires a canonical branch and valid project host configuration; values omitted.", + }); } /** The same selector must reach original review, normalized review and execution. */ diff --git a/src/backends/native-project-cache.ts b/src/backends/native-project-cache.ts index be57bd39..3156eb90 100644 --- a/src/backends/native-project-cache.ts +++ b/src/backends/native-project-cache.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import type { NativeProjectReview } from "./native-project-review.ts"; import { @@ -16,7 +17,7 @@ const SHA = /^[a-f0-9]{64}$/; /** Source sharing is meaningful only for active bind mounts, not image-only services. */ export function nativeSharedSourceFlags(plan: unknown): readonly string[] { if (!(isRecord(plan) && isRecord(plan.services))) { - throw new Error("Native source review is invalid."); + throw new ClosedCliError("Native source review is invalid."); } const shared = Object.values(plan.services).some( (service) => @@ -82,7 +83,7 @@ export async function publishNativeCacheSource(opts: { publication.state === "guest-content-verified-no-job-started" ) ) { - throw new Error( + throw new ClosedCliError( "Native cache source publication did not return a verified revision." ); } @@ -91,12 +92,12 @@ export async function publishNativeCacheSource(opts: { export function nativeCacheInitializers(plan: unknown): ReadonlySet { if (!(isRecord(plan) && isRecord(plan.services))) { - throw new Error("Native cache review is invalid."); + throw new ClosedCliError("Native cache review is invalid."); } const initializers = new Set(); for (const [name, service] of Object.entries(plan.services)) { if (!isRecord(service)) { - throw new Error("Native service review is invalid."); + throw new ClosedCliError("Native service review is invalid."); } if (service.active === true && isRecord(service.dependency_cache)) { initializers.add(name); @@ -115,7 +116,9 @@ export async function nativeStartCacheSource(opts: { }): ReturnType { if (opts.restoreRevision) { if (!SHA.test(opts.restoreRevision)) { - throw new Error("Native retained cache source revision is invalid."); + throw new ClosedCliError( + "Native retained cache source revision is invalid." + ); } return { flags: ["--source-revision", opts.restoreRevision], diff --git a/src/backends/native-project-cleanup-command.ts b/src/backends/native-project-cleanup-command.ts index a33c7f9f..cc92a427 100644 --- a/src/backends/native-project-cleanup-command.ts +++ b/src/backends/native-project-cleanup-command.ts @@ -1,6 +1,6 @@ import { CliUsageError } from "../cli/command.ts"; import { resolveEffectiveBranch } from "../lib/branches.ts"; -import { emitCliResult, okResult } from "../lib/cli-result.ts"; +import { ClosedCliError, emitCliResult, okResult } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { findProjectContext, @@ -34,7 +34,7 @@ const GUIDANCE = "No uncertain stop was replayed. Preserve receipts and run mappings. Pending cleanup requires explicit owned recovery; review every graph sharing the pool before stopping it, and do not roll over its boot repeatedly."; function refused(): Error { - return new Error( + return new ClosedCliError( "Native cleanup inspection is unconfirmed; owned mapping or observations are unavailable or changed. Values omitted; no recovery was requested." ); } diff --git a/src/backends/native-project-dependencies.ts b/src/backends/native-project-dependencies.ts index 64c37b29..a0e05d5c 100644 --- a/src/backends/native-project-dependencies.ts +++ b/src/backends/native-project-dependencies.ts @@ -1,5 +1,6 @@ import { isAbsolute } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { readNativeSelection } from "./native-project-selection.ts"; @@ -46,9 +47,11 @@ export function prepareNativeDependencyServices(opts: { for (const dependency of opts.dependencies) { const service = opts.services[dependency.service]; if (!service || (service.init !== undefined && service.init !== true)) { - throw new Error( - "Native host dependencies require init: true; explicit service settings were not overridden." - ); + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: + "Native host dependencies require init: true; explicit service settings were not overridden.", + }); } selected.push(service); } @@ -57,10 +60,13 @@ export function prepareNativeDependencyServices(opts: { } } +/** The private listener selection (or its discovery) did not validate. */ function refused(): Error { - return new Error( - "Native host dependencies require a bounded explicit listener selection; values omitted." - ); + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native host dependencies require a bounded explicit listener selection; values omitted.", + }); } function port(value: unknown): value is number { return ( diff --git a/src/backends/native-project-dependency-refresh.ts b/src/backends/native-project-dependency-refresh.ts index 62a914ba..b63d2522 100644 --- a/src/backends/native-project-dependency-refresh.ts +++ b/src/backends/native-project-dependency-refresh.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { loadNativeProjectRun, @@ -51,7 +52,7 @@ function refused(cause?: unknown): Error { : ""; diagnostic = ` Native diagnostic: ${cause.nativeCode}${nested}.`; } - return new Error( + return new ClosedCliError( `Native dependency refresh identity or outcome is unconfirmed; inspect the owned graph before retrying. No request or command was replayed.${diagnostic}` ); } @@ -79,7 +80,7 @@ export async function refreshNativeProjectDependencies(opts: { }) { const mapping = await loadNativeProjectRun(opts.scope); if (!mapping) { - throw new Error( + throw new ClosedCliError( "Native project has not been started; dependency refresh requires a started project." ); } diff --git a/src/backends/native-project-down.ts b/src/backends/native-project-down.ts index ecbf0837..5b8f4083 100644 --- a/src/backends/native-project-down.ts +++ b/src/backends/native-project-down.ts @@ -1,5 +1,6 @@ import { basename } from "node:path"; import { isDeepStrictEqual } from "node:util"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveProjectEnvConfig, @@ -23,12 +24,12 @@ import { } from "./native-runtime-client.ts"; function refused(): Error { - return new Error( + return new ClosedCliError( "Native down cleanup is unconfirmed; retained mapping and runtime state require inspection. No cleanup was replayed." ); } function finalizationRefused(): Error { - return new Error( + return new ClosedCliError( "Native down frontend finalization is unconfirmed or its ownership changed; retained mapping and runtime state require inspection. No cleanup was replayed." ); } @@ -200,7 +201,7 @@ export async function nativeDownEnvironment(opts: { readonly serviceNames: readonly string[]; }): Promise>> { if (!Object.hasOwn(opts.run, "effectiveEnvName")) { - throw new Error( + throw new ClosedCliError( "Native down lifecycle hooks require persisted startup environment selection; legacy mapping must not guess an overlay." ); } @@ -235,7 +236,7 @@ export async function nativeDownEnvironment(opts: { target: "host", }); } catch { - throw new Error( + throw new ClosedCliError( "Native down lifecycle environment is unavailable; values omitted. No cleanup was requested." ); } diff --git a/src/backends/native-project-exec.ts b/src/backends/native-project-exec.ts index efa97041..b9933547 100644 --- a/src/backends/native-project-exec.ts +++ b/src/backends/native-project-exec.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { prepareNativeExecEnvironment } from "./native-exec-environment.ts"; import { refreshNativeProjectDependencies } from "./native-project-dependency-refresh.ts"; @@ -11,7 +12,7 @@ import { const SERVICE = /^[A-Za-z0-9_.-]{1,128}$/; function refused(): Error { - return new Error( + return new ClosedCliError( "Native exec identity or response is unconfirmed; command effects may have occurred. No request was replayed." ); } @@ -126,14 +127,14 @@ export async function nativeProjectExec(opts: { opts.workdir.includes("\0") || Buffer.byteLength(opts.workdir) > 4096)) ) { - throw new Error( + throw new ClosedCliError( "Native exec requires one service and bounded command arguments with an optional absolute workdir." ); } const refreshed = await refreshNativeProjectDependencies(opts); const before = await nativeProjectPs(opts); if (!before.run) { - throw new Error( + throw new ClosedCliError( "Native project has not been started; no owned graph mapping exists." ); } diff --git a/src/backends/native-project-finalization.ts b/src/backends/native-project-finalization.ts index 3271b246..fd949b13 100644 --- a/src/backends/native-project-finalization.ts +++ b/src/backends/native-project-finalization.ts @@ -10,6 +10,7 @@ import { unlink, } from "node:fs/promises"; import { join } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { isNativeHttpsLeaseIdentity, @@ -131,7 +132,7 @@ export function isNativeProjectFinalizationToken( ); } function refused(): Error { - return new Error( + return new ClosedCliError( "Native frontend finalization is unconfirmed or its ownership changed; no replacement owner may start." ); } diff --git a/src/backends/native-project-host-aliases.ts b/src/backends/native-project-host-aliases.ts index 643cfb27..24b5d9c7 100644 --- a/src/backends/native-project-host-aliases.ts +++ b/src/backends/native-project-host-aliases.ts @@ -1,10 +1,13 @@ +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; function refused(): Error { - return new Error( - "Native host alias selection conflicts with declared configuration; values omitted." - ); + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native host alias selection conflicts with declared configuration; values omitted.", + }); } function existingAliases(value: unknown): string[] { if (value === undefined) { diff --git a/src/backends/native-project-https.ts b/src/backends/native-project-https.ts index cc9dade4..0a924f51 100644 --- a/src/backends/native-project-https.ts +++ b/src/backends/native-project-https.ts @@ -25,6 +25,7 @@ import { connect, createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { connect as tlsConnect } from "node:tls"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { listenPublishedUnixSocket } from "../lib/unix-socket-publish.ts"; import { checkNativeHttpsPort } from "./native-https-port.ts"; @@ -114,12 +115,12 @@ export function nativeHttpsVerificationError( ) { code = error.code; } - return new Error( + return new ClosedCliError( `Native HTTPS verification failed (${code}); peer values omitted.` ); } function refused(): Error { - return new Error( + return new ClosedCliError( "Native HTTPS startup or ownership verification failed; values omitted. Inspect retained owned state before retrying." ); } diff --git a/src/backends/native-project-image-admission.ts b/src/backends/native-project-image-admission.ts index 970ae42d..3e032ef2 100644 --- a/src/backends/native-project-image-admission.ts +++ b/src/backends/native-project-image-admission.ts @@ -1,3 +1,4 @@ +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { invokeNativeRuntime, @@ -11,9 +12,25 @@ const KINDS = new Set(["docker_hub", "image_id"]); /** Admission is pure in the runtime, so each request is short and bounded. */ const ADMISSION_TIMEOUT_MS = 10_000; -function refused(): Error { - return new Error( - "Native project image reference refused before runtime startup: use a public Docker Hub reference (shorthand such as alpine:3.21 is accepted) or a sha256 image ID; no VM, provider or image acquisition was started." +/** An authored image the candidate cannot admit: a closed, coded refusal. */ +function refused(): HackCliError { + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: + "Native project image reference refused before runtime startup: use a public Docker Hub reference (shorthand such as alpine:3.21 is accepted) or a sha256 image ID; no VM, provider or image acquisition was started.", + }); +} + +/** An admission reply outside its closed shape is a runtime integrity failure, not a refusal. */ +function malformed(): ClosedCliError { + return new ClosedCliError( + "Native runtime returned an invalid image admission response; no VM, provider or image acquisition was started." + ); +} + +function canceled(): ClosedCliError { + return new ClosedCliError( + "Native image admission was canceled; no VM, provider or image acquisition was started." ); } @@ -24,8 +41,9 @@ function refused(): Error { * this adapter only collects distinct references and checks the closed response. * Content IDs need no registry and are not sent. Admission does not pin or * rewrite images: acquisition later re-normalizes with the same runtime rule. - * A refusal or malformed response throws a value-free error; other runtime - * request failures propagate unchanged. + * An unsupported reference throws a value-free `E_NATIVE_PROJECT_UNSUPPORTED` + * refusal; a malformed reply or cancellation throws a value-free closed error. + * Other runtime request failures propagate unchanged. */ export async function admitNativeProjectImages(opts: { readonly runtime: NativeRuntimeSelection; @@ -46,7 +64,7 @@ export async function admitNativeProjectImages(opts: { } for (const reference of references) { if (opts.signal?.aborted) { - throw refused(); + throw canceled(); } let admitted: unknown; try { @@ -76,7 +94,7 @@ export async function admitNativeProjectImages(opts: { KINDS.has(admitted.kind) ) ) { - throw refused(); + throw malformed(); } } } diff --git a/src/backends/native-project-input.ts b/src/backends/native-project-input.ts index 63be14fc..ab83234d 100644 --- a/src/backends/native-project-input.ts +++ b/src/backends/native-project-input.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { constants } from "node:fs"; import { lstat, open } from "node:fs/promises"; import { join } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveProjectEnvConfig, @@ -26,9 +27,11 @@ export type NativeProjectInput = { }; function refused(): Error { - return new Error( - "Native project input refused: unsupported or invalid Compose/environment configuration; values omitted" - ); + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: + "Native project input refused: unsupported or invalid Compose/environment configuration; values omitted", + }); } async function readCompose(path: string): Promise { diff --git a/src/backends/native-project-interrupted-cleanup.ts b/src/backends/native-project-interrupted-cleanup.ts index 0d69cecc..c12d1485 100644 --- a/src/backends/native-project-interrupted-cleanup.ts +++ b/src/backends/native-project-interrupted-cleanup.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; @@ -11,13 +12,13 @@ import { const SHA256 = /^[a-f0-9]{64}$/; function refused(): Error { - return new Error( + return new ClosedCliError( "Native interrupted startup cleanup is unconfirmed; inspect owned state before retrying." ); } /** A pending shutdown is fenced; it cannot reuse a failed-start selection. */ -export class NativePartialShutdownError extends Error { +export class NativePartialShutdownError extends ClosedCliError { constructor() { super( "Native retaining shutdown is incomplete; retained state and mapping require inspection. No uncertain stop was replayed. Inspect with hack doctor --native-cleanup inspect and follow explicit owned cleanup recovery; do not repeat down or restart the pool blindly." diff --git a/src/backends/native-project-network.ts b/src/backends/native-project-network.ts index dd2c9bfe..9549adec 100644 --- a/src/backends/native-project-network.ts +++ b/src/backends/native-project-network.ts @@ -1,10 +1,13 @@ import { isIP } from "node:net"; +import { HackCliError } from "../lib/cli-result.ts"; const LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; function refused(): Error { - return new Error( - "HACK_NATIVE_ALLOW_HOSTS requires at most 32 unique canonical public DNS names; wildcards, IP addresses, local names and empty entries are refused. Values omitted." - ); + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "HACK_NATIVE_ALLOW_HOSTS requires at most 32 unique canonical public DNS names; wildcards, IP addresses, local names and empty entries are refused. Values omitted.", + }); } /** Match native NetworkIntent admission; never resolve or widen the selection here. */ diff --git a/src/backends/native-project-observe.ts b/src/backends/native-project-observe.ts index 60aee656..aaa1d91b 100644 --- a/src/backends/native-project-observe.ts +++ b/src/backends/native-project-observe.ts @@ -293,7 +293,7 @@ export function requireComposeOperationAvailable( env: Readonly> = process.env ): void { if (env.HACK_RUNTIME_BACKEND === "native") { - throw new Error( + throw new ClosedCliError( `Native runtime operation '${operation}' is unavailable through this command; refusing Compose fallback.` ); } diff --git a/src/backends/native-project-one-off.ts b/src/backends/native-project-one-off.ts index e7780a2a..8373b094 100644 --- a/src/backends/native-project-one-off.ts +++ b/src/backends/native-project-one-off.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { prepareNativeExecEnvironment } from "./native-exec-environment.ts"; import { refreshNativeProjectDependencies } from "./native-project-dependency-refresh.ts"; @@ -14,7 +15,7 @@ const SERVICE = /^[A-Za-z0-9_.-]{1,128}$/; const DIGEST = /^[a-f0-9]{64}$/; const JOB = /^[a-f0-9]{32}$/; function refused(): Error { - return new Error( + return new ClosedCliError( "Native one-off identity or cleanup is unconfirmed; inspect owned job state before retrying. No command was replayed." ); } @@ -52,13 +53,13 @@ export async function nativeProjectOneOff(opts: { opts.workdir.includes("\0") || Buffer.byteLength(opts.workdir) > 4096)) ) { - throw new Error( + throw new ClosedCliError( "Native run requires a service, bounded arguments and an optional absolute workdir." ); } const mapping = await loadNativeProjectRun(opts.scope); if (!mapping) { - throw new Error( + throw new ClosedCliError( "Native run currently requires a started project; run up first." ); } diff --git a/src/backends/native-project-process.ts b/src/backends/native-project-process.ts index 89ea2d65..b898d8f5 100644 --- a/src/backends/native-project-process.ts +++ b/src/backends/native-project-process.ts @@ -1,4 +1,5 @@ import { isAbsolute } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { type NativeAuthoredReceipt, @@ -12,7 +13,7 @@ import type { NativeAuthoredStorageTool } from "./native-authored-storage-tool.t import { invokeNativeRuntime, type NativeRuntimeSelection, - readNativeFailureCode, + readNativeFailureCodes, rethrowNativeInputFailure, writeNativePrivateInput, } from "./native-runtime-client.ts"; @@ -24,6 +25,8 @@ const MAX_INPUT = 256 * 1024; export type NativeExitDiagnostic = { readonly exitCode: number; readonly nativeCode?: string; + /** Bounded cause the runtime allows with `nativeCode`, such as a wrapped stopped runtime. */ + readonly nativeCauseCode?: string; }; type ProcessOptions = { @@ -77,7 +80,7 @@ export async function serveNativeProjectGraph( value.kind !== "graph_foreground_ready" || value.run !== opts.run ) { - throw new Error( + throw new ClosedCliError( "Native graph readiness identity is invalid or canceled." ); } @@ -124,7 +127,7 @@ export async function serveNativeAuthoredProjectGraph( expected.provenance.run !== opts.run || opts.startupTimeoutMs > 300_000 ) { - throw new Error( + throw new ClosedCliError( "Native authored source selection is invalid; values omitted." ); } @@ -184,7 +187,7 @@ export async function serveNativeAuthoredProjectGraph( const current = parseNativeAuthoredControl(status, receipt, "status"); const assertRunning = () => { if (interrupted() || !nativeSnapshotReady(current)) { - throw new Error( + throw new ClosedCliError( "Native graph readiness identity is invalid or canceled." ); } @@ -216,7 +219,7 @@ export async function serveNativeAuthoredProjectGraph( "status" ); if (interrupted() || !nativeSnapshotReady(fresh)) { - throw new Error( + throw new ClosedCliError( "Native graph readiness identity changed; values omitted." ); } @@ -265,7 +268,9 @@ function assertProcessSelection(opts: ProcessOptions): void { (opts.privateInput?.byteLength ?? 0) > MAX_INPUT || canceledAtEntry(opts) ) { - throw new Error("Native graph startup input is invalid or canceled."); + throw new ClosedCliError( + "Native graph startup input is invalid or canceled." + ); } } @@ -321,7 +326,8 @@ async function serveGraphProcess( owner.abort(); drainTimer = setTimeout(() => drain.abort(), 250); }); - const failureCode = readNativeFailureCode(child.stderr, drain.signal); + const failureCodes = readNativeFailureCodes(child.stderr, drain.signal); + const failureCode = failureCodes.then((failure) => failure?.code); let ready = false; let canceled = false; let timedOut = false; @@ -408,7 +414,7 @@ async function serveGraphProcess( onReady: async (value, interrupted) => { await opts.onReady(value, interrupted, owner.signal, () => { if (interrupted()) { - throw new Error( + throw new ClosedCliError( "Native graph publication interrupted; values omitted." ); } @@ -427,7 +433,7 @@ async function serveGraphProcess( nativeCode: failure, }); if (timedOut || canceled || !ready) { - throw new Error( + throw new ClosedCliError( `Native graph startup was interrupted or failed${failure ? ` (${failure})` : ""}; inspect owned state before retrying.` ); } @@ -445,13 +451,16 @@ async function serveGraphProcess( clearTimeout(killTimer); } const exitCode = await child.exited; - const nativeCode = await failureCode; + const failure = await failureCodes; + const nativeCode = failure?.code; + const nativeCauseCode = failure?.causeCode; clearTimeout(drainTimer); drain.abort(); try { opts.onExitDiagnostic?.({ exitCode, ...(nativeCode ? { nativeCode } : {}), + ...(nativeCauseCode ? { nativeCauseCode } : {}), }); } catch { // Observation must never replace the startup result or cleanup authority. @@ -478,7 +487,7 @@ async function requireGroupAbsent(group: number): Promise { } await Bun.sleep(50); } - throw new Error( + throw new ClosedCliError( "Native foreground child group settlement is unknown; values omitted." ); } @@ -514,7 +523,9 @@ async function consumeGraphOutput(opts: { } total += chunk.value.byteLength; if (total > MAX_OUTPUT) { - throw new Error("Native graph control output exceeded its budget."); + throw new ClosedCliError( + "Native graph control output exceeded its budget." + ); } if (ready) { continue; @@ -523,7 +534,7 @@ async function consumeGraphOutput(opts: { const end = line.indexOf("\n"); if (end < 0) { if (readyFrameSize(line, opts.strictReady) > opts.readyLimit) { - throw new Error( + throw new ClosedCliError( "Native graph readiness response exceeded its budget." ); } @@ -533,7 +544,9 @@ async function consumeGraphOutput(opts: { opts.strictReady && Buffer.byteLength(line.slice(0, end)) > opts.readyLimit ) { - throw new Error("Native graph readiness response exceeded its budget."); + throw new ClosedCliError( + "Native graph readiness response exceeded its budget." + ); } await acceptGraphReady({ ...opts, line: line.slice(0, end) }); ready = true; @@ -563,13 +576,17 @@ async function acceptGraphReady(opts: { try { value = JSON.parse(opts.line); } catch { - throw new Error("Native graph readiness response is invalid."); + throw new ClosedCliError("Native graph readiness response is invalid."); } if (opts.interrupted()) { - throw new Error("Native graph readiness identity is invalid or canceled."); + throw new ClosedCliError( + "Native graph readiness identity is invalid or canceled." + ); } await opts.onReady(value, opts.interrupted); if (opts.strictReady && opts.interrupted()) { - throw new Error("Native graph readiness identity is invalid or canceled."); + throw new ClosedCliError( + "Native graph readiness identity is invalid or canceled." + ); } } diff --git a/src/backends/native-project-recovery.ts b/src/backends/native-project-recovery.ts index f1d68621..638203c1 100644 --- a/src/backends/native-project-recovery.ts +++ b/src/backends/native-project-recovery.ts @@ -1,6 +1,7 @@ import { constants } from "node:fs"; import { lstat, open, realpath, rmdir } from "node:fs/promises"; import { basename, join } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { isNativeHttpsLeaseIdentity, @@ -22,7 +23,7 @@ import { const PROCESS_LINE = /^\s*(\d+)\s+(.+?)\s*$/; function refused(): Error { - return new Error( + return new ClosedCliError( "Native frontend recovery cannot prove the old owner and effects are gone; retained data was not changed." ); } diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cbb5a74d..cd7ac3ce 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; @@ -83,7 +84,7 @@ async function verifyNativeRestartListeners(opts: { file.size === 0 || file.size > 16 * 1024 * 1024 ) { - throw new Error( + throw new ClosedCliError( "Native restart needs its bundled dependency relay before cleanup; the current graph was not stopped." ); } @@ -115,7 +116,7 @@ async function verifyNativeRestartListeners(opts: { args: ["graph", "dependency-plan", "--dependencies", path, "--json"], }); } catch { - throw new Error( + throw new ClosedCliError( "Native restart host dependency listener is unavailable or changed; refresh its private selection before restart. The current graph was not stopped." ); } @@ -125,7 +126,7 @@ async function verifyNativeRestartListeners(opts: { typeof result.dependency_plan_id !== "string" || !SHA.test(result.dependency_plan_id) ) { - throw new Error( + throw new ClosedCliError( "Native restart host dependency selection was not confirmed; the current graph was not stopped." ); } @@ -149,7 +150,7 @@ async function readNativeRestartDependencyIntent(opts: { try { const value = await readNativeSelection(opts.path); if (!(isRecord(value) && Array.isArray(value.dependencies))) { - throw new Error("Invalid dependency intent"); + throw new ClosedCliError("Invalid dependency intent"); } return parseNativeHostDependencies({ services: opts.services, @@ -163,7 +164,7 @@ async function readNativeRestartDependencyIntent(opts: { }, }); } catch { - throw new Error( + throw new ClosedCliError( "Native restart dependency intent is invalid; values omitted." ); } @@ -188,7 +189,7 @@ export function requireNativeRestartNetwork( ) { return; } - throw new Error( + throw new ClosedCliError( "Native restart network selection differs from the owned pool; migrate its network policy explicitly before restart. The current graph was not stopped." ); } @@ -205,7 +206,7 @@ export function nativeRestartSelection(opts: { run.profiles === undefined || run.aws === undefined ) { - throw new Error( + throw new ClosedCliError( "Native restart requires recorded startup selectors; legacy state needs explicit recovery." ); } @@ -215,7 +216,7 @@ export function nativeRestartSelection(opts: { JSON.stringify(normalizeNativeProfiles(opts.profiles)) !== JSON.stringify(run.profiles)) ) { - throw new Error( + throw new ClosedCliError( "Native restart cannot change the startup environment or profiles; the current graph was not stopped." ); } @@ -240,7 +241,7 @@ async function inspectRestartGraph(opts: { invoke: opts.invoke, }); } catch { - throw new Error( + throw new ClosedCliError( "Native restart cannot confirm the stopped retained graph; no listener discovery or cleanup was requested." ); } @@ -266,7 +267,7 @@ async function stoppedRestartMode(opts: { isRecord(observed.receipt) && observed.receipt.phase === "stopped-data-retained")) ) { - throw new Error( + throw new ClosedCliError( "Native restart cannot confirm the stopped retained graph; no listener discovery or cleanup was requested." ); } @@ -302,7 +303,9 @@ async function pinRestartImages(opts: { typeof pinned.image_id !== "string" || !IMAGE.test(pinned.image_id) ) { - throw new Error("Native restart image selection failed before cleanup."); + throw new ClosedCliError( + "Native restart image selection failed before cleanup." + ); } spec.image = pinned.image_id; } @@ -349,7 +352,7 @@ export async function preflightNativeRestart(opts: { args: ["runtime", "probe", "--profile", "development", "--json"], }); if (!isRecord(admission) || admission.admitted !== true) { - throw new Error( + throw new ClosedCliError( "Native restart admission failed before cleanup; inspect runtime probe --profile development. The current graph was not stopped." ); } @@ -449,7 +452,7 @@ export async function preflightNativeRestart(opts: { input: { ...input, normalizedComposeJson: JSON.stringify(compose) }, run: async (review) => { if (review.namespace !== opts.run.namespace) { - throw new Error( + throw new ClosedCliError( "Native restart configuration changed; the current graph was not stopped." ); } @@ -496,7 +499,7 @@ export async function preflightNativeRestart(opts: { invoke: deps.invoke, }); if (!confirmedNativeRetainedGraph(observed, opts.run)) { - throw new Error( + throw new ClosedCliError( "Native restart stopped recovery changed during review; no cleanup was requested." ); } diff --git a/src/backends/native-project-restart.ts b/src/backends/native-project-restart.ts index fd981d06..0a408e5d 100644 --- a/src/backends/native-project-restart.ts +++ b/src/backends/native-project-restart.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { captureNativeProjectFinalization, type NativeProjectFinalizationToken, @@ -59,7 +60,7 @@ export async function restartNativeProject(opts: { const current = await deps.load(opts.scope); const run = intent?.run ?? current; if (!run) { - throw new Error( + throw new ClosedCliError( "Native restart has no existing run; use hack up for initial startup." ); } @@ -70,7 +71,9 @@ export async function restartNativeProject(opts: { current.owner !== run.owner || current.namespace !== run.namespace) ) { - throw new Error("Native restart mapping changed; no cleanup requested."); + throw new ClosedCliError( + "Native restart mapping changed; no cleanup requested." + ); } nativeRestartSelection({ run, @@ -78,7 +81,7 @@ export async function restartNativeProject(opts: { profiles: opts.profiles, }); if (!current && intent && intent.phase !== "cleaned") { - throw new Error( + throw new ClosedCliError( "Native restart cleanup or down hooks were interrupted; inspect the pending intent before recovery." ); } @@ -97,7 +100,7 @@ export async function restartNativeProject(opts: { await opts.down(); intent = await deps.cleaned({ ...opts.scope, expected: intent }); } else if (intent.phase !== "cleaned") { - throw new Error( + throw new ClosedCliError( "Native restart cleanup or down hooks were interrupted; inspect the pending intent before recovery." ); } diff --git a/src/backends/native-project-restore.ts b/src/backends/native-project-restore.ts index da477f80..67adc971 100644 --- a/src/backends/native-project-restore.ts +++ b/src/backends/native-project-restore.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { nativeCacheInitializers } from "./native-project-cache.ts"; import type { NativeProjectReview } from "./native-project-review.ts"; @@ -26,7 +27,7 @@ export function validateNativeRestoreSelection(opts: { typeof selected.generation !== "string" || !SHA.test(selected.generation) ) { - throw new Error( + throw new ClosedCliError( "Native restore selection changed; retained data was not adopted." ); } @@ -80,13 +81,13 @@ export async function verifyNativeSourceCompatibility(opts: { SHA.test(result.source_revision)) ) ) { - throw new Error( + throw new ClosedCliError( "Native shared-source compatibility changed; retained data was not adopted." ); } const cached = nativeCacheInitializers(opts.review.report.plan).size > 0; if ((result.source_revision === null) !== !cached) { - throw new Error( + throw new ClosedCliError( "Native shared-source compatibility changed; retained data was not adopted." ); } @@ -127,7 +128,7 @@ export async function selectNativeProjectRestore(opts: { opts.review.retainedGeneration !== undefined && opts.review.retainedGeneration !== selection.generation ) { - throw new Error( + throw new ClosedCliError( "Native restore selection changed; retained data was not adopted." ); } diff --git a/src/backends/native-project-retained-images.ts b/src/backends/native-project-retained-images.ts index f02d1a2c..2469043d 100644 --- a/src/backends/native-project-retained-images.ts +++ b/src/backends/native-project-retained-images.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; @@ -42,7 +43,7 @@ export async function selectNativeRetainedImages(opts: { isRecord(observed.receipt) ) ) { - throw new Error( + throw new ClosedCliError( "Native retained image selection changed; no images were resolved." ); } @@ -73,7 +74,7 @@ function imagesFromReceipt(opts: { !SHA.test(opts.originalSha256) || !isRecord(receipt.resources) ) { - throw new Error( + throw new ClosedCliError( "Native retained image provenance is invalid; no images were resolved." ); } @@ -91,7 +92,7 @@ function imagesFromReceipt(opts: { typeof resource.image !== "string" || !IMAGE.test(resource.image) ) { - throw new Error( + throw new ClosedCliError( "Native retained image identity is invalid; no images were resolved." ); } @@ -112,7 +113,7 @@ function activeReceipt(value: unknown, saved: NativeProjectRun) { value.receipt.plan_id !== saved.planId || !isRecord(value.receipt.resources) ) { - throw new Error( + throw new ClosedCliError( "Native active image selection changed; the current graph was not stopped." ); } @@ -147,7 +148,7 @@ export async function selectNativeActiveImages(opts: { SERVICE.test(resource.key) ); if (!isRecord(service) || typeof service.key !== "string") { - throw new Error( + throw new ClosedCliError( "Native active image selection has no service authority; the current graph was not stopped." ); } @@ -164,13 +165,13 @@ export async function selectNativeActiveImages(opts: { ? receipt.resources[`container:${proof.service}`] : undefined; if (!isRecord(resource) || resource.id !== proof.container) { - throw new Error( + throw new ClosedCliError( "Native active image identity changed; the current graph was not stopped." ); } const images = imagesFromReceipt({ ...opts, receipt }); if (JSON.stringify([...initialImages]) !== JSON.stringify([...images])) { - throw new Error( + throw new ClosedCliError( "Native active image selection changed; the current graph was not stopped." ); } diff --git a/src/backends/native-project-retained-startup.ts b/src/backends/native-project-retained-startup.ts index c612d2dc..c3110954 100644 --- a/src/backends/native-project-retained-startup.ts +++ b/src/backends/native-project-retained-startup.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { inspectNativeProjectGraph } from "./native-project-inspect.ts"; import { confirmedNativeRetainedGraph } from "./native-project-retained.ts"; @@ -46,7 +47,7 @@ export async function preflightNativeRetainedStartup(opts: { SHA.test(value.selection) ) ) { - throw new Error( + throw new ClosedCliError( "Native retained startup eligibility changed; no hooks or runtime start were requested." ); } @@ -62,7 +63,7 @@ export async function preflightNativeRetainedStartup(opts: { } function requireActive(signal: AbortSignal | undefined): void { if (signal?.aborted) { - throw new Error( + throw new ClosedCliError( "Native retained startup was canceled; no hooks or runtime start were requested." ); } @@ -77,7 +78,7 @@ export async function verifyNativeRetainedMapping(opts: { opts.run && JSON.stringify(await opts.load(opts.scope)) !== JSON.stringify(opts.run) ) { - throw new Error( + throw new ClosedCliError( "Native retained run mapping changed before runtime start; no start was requested." ); } @@ -97,7 +98,7 @@ export async function verifyNativeResumedRetainedGraph(opts: { run: opts.run.run, }); if (!confirmedNativeRetainedGraph(observed, opts.run)) { - throw new Error( + throw new ClosedCliError( "Native resumed retained graph differs; no graph restore was requested." ); } diff --git a/src/backends/native-project-review.ts b/src/backends/native-project-review.ts index 173084f3..78f307cc 100644 --- a/src/backends/native-project-review.ts +++ b/src/backends/native-project-review.ts @@ -1,6 +1,7 @@ import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { nativeProjectBranchArgs, @@ -80,7 +81,7 @@ export async function selectNativeActiveReview(opts: { selected.boot.length > 128 || CONTROL.test(selected.boot) ) { - throw new Error( + throw new ClosedCliError( "Native active review identity changed; the current graph was not stopped." ); } @@ -109,9 +110,11 @@ function activeReviewService(plan: Readonly>): string { services[key].active === true ); if (!service) { - throw new Error( - "Native active review has no supported service authority; the current graph was not stopped." - ); + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: + "Native active review has no supported service authority; the current graph was not stopped.", + }); } return service; } @@ -133,7 +136,7 @@ export async function verifyNativeActiveReview(opts: { invoke: opts.invoke ?? invokeNativeRuntime, }); if (JSON.stringify(current) !== JSON.stringify(opts.proof)) { - throw new Error( + throw new ClosedCliError( "Native active review identity changed; the current graph was not stopped." ); } @@ -165,9 +168,10 @@ export async function selectNativeProjectReviewIdentity(opts: { file.startsWith("../") || !DIGEST.test(opts.input.originalSha256) ) { - throw new Error( - "Native project review requires an owned original Compose file." - ); + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: "Native project review requires an owned original Compose file.", + }); } let projectArgs = [ "--project", @@ -189,7 +193,7 @@ export async function selectNativeProjectReviewIdentity(opts: { !DIGEST.test(original.plan.namespace) || original.plan.compose_sha256 !== opts.input.originalSha256 ) { - throw new Error( + throw new ClosedCliError( "Native project input changed before review; prepare it again." ); } @@ -244,7 +248,7 @@ export async function selectNativeProjectReviewIdentity(opts: { unbranched.plan.source !== canonicalProject || unbranched.plan.compose_sha256 !== opts.input.originalSha256 ) { - throw new Error( + throw new ClosedCliError( "Native retained project review changed; retained data was not adopted." ); } @@ -285,7 +289,7 @@ export async function withNativeProjectReview(opts: { opts.reviewIdentity && JSON.stringify(opts.reviewIdentity) !== JSON.stringify(identity) ) { - throw new Error( + throw new ClosedCliError( "Native project review selection changed; retained data was not adopted." ); } @@ -319,7 +323,9 @@ export async function withNativeProjectReview(opts: { !DIGEST.test(report.plan_id) || report.plan.namespace !== namespace ) { - throw new Error("Native project returned an invalid reviewed identity."); + throw new ClosedCliError( + "Native project returned an invalid reviewed identity." + ); } return await opts.run({ planId: report.plan_id, diff --git a/src/backends/native-project-routing.ts b/src/backends/native-project-routing.ts index 780dd2a8..bb78fb0e 100644 --- a/src/backends/native-project-routing.ts +++ b/src/backends/native-project-routing.ts @@ -1,11 +1,14 @@ +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { hasOnlyNativeCacheLabels } from "./native-project-cache.ts"; const ROUTE_LABELS = new Set(["caddy", "caddy.reverse_proxy", "caddy.tls"]); function refused(): Error { - return new Error( - "Native routes require reviewed Caddy labels and existing matching native HTTP health probes; no probe or route was invented." - ); + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: + "Native routes require reviewed Caddy labels and existing matching native HTTP health probes; no probe or route was invented.", + }); } function labelKeys(labels: unknown): string[] | undefined { if (labels === undefined) { diff --git a/src/backends/native-project-run.ts b/src/backends/native-project-run.ts index d79af2b4..5ae8a1a5 100644 --- a/src/backends/native-project-run.ts +++ b/src/backends/native-project-run.ts @@ -11,6 +11,7 @@ import { unlink, } from "node:fs/promises"; import { join, relative } from "node:path"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { isNativeProjectFinalizationToken, @@ -54,8 +55,9 @@ export type NativeProjectRunScope = { readonly nativeHome: string; readonly branch: string | null; }; +/** Integrity or ownership refusal of the saved mapping: closed, no stack. */ function refused(): Error { - return new Error( + return new ClosedCliError( "Native project run mapping is unsafe, changed, or owned by another run; inspect native state before recovery." ); } @@ -88,9 +90,10 @@ export function normalizeNativeProfiles( CONTROL.test(profile) ) ) { - throw new Error( - "Native profiles require at most 64 bounded nonempty names." - ); + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Native profiles require at most 64 bounded nonempty names.", + }); } return [...new Set(profiles)].sort(); } @@ -301,7 +304,7 @@ export async function withNativeRestartLock( try { await mkdir(lock, { mode: 0o700 }); } catch { - throw new Error( + throw new ClosedCliError( "Native restart is already owned or was interrupted; inspect the pending restart before recovery." ); } diff --git a/src/backends/native-project-selection.ts b/src/backends/native-project-selection.ts index 4612988f..2cc8cf07 100644 --- a/src/backends/native-project-selection.ts +++ b/src/backends/native-project-selection.ts @@ -1,12 +1,15 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; import { isAbsolute } from "node:path"; +import { HackCliError } from "../lib/cli-result.ts"; const LIMIT = 65_536; function refused(): Error { - return new Error( - "Native selection requires a bounded, unchanged regular JSON file; values omitted." - ); + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native selection requires a bounded, unchanged regular JSON file; values omitted.", + }); } /** Selection files contain public intent, never credentials or durable process authority. */ export async function readNativeSelection(path: string): Promise { diff --git a/src/backends/native-project-source-preflight.ts b/src/backends/native-project-source-preflight.ts index 5b1b1b2d..b5a61bb0 100644 --- a/src/backends/native-project-source-preflight.ts +++ b/src/backends/native-project-source-preflight.ts @@ -1,3 +1,4 @@ +import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { invokeNativeRuntime, @@ -30,7 +31,7 @@ export async function preflightNativeProjectSource(opts: { result.source_admitted !== true || typeof result.pool_initialized !== "boolean" ) { - throw new Error( + throw new ClosedCliError( "Native source admission returned an invalid receipt; startup hooks were not run." ); } diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index d5fdfa1c..73f410df 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { type ComposeEnvFingerprintRecord, withComposeServiceEnvFingerprints, @@ -107,9 +108,11 @@ export function validateHttpsSelection( selection.httpsPort < 1 || selection.httpsPort > 65_535) ) { - throw new Error( - "Native HTTPS requires an absolute HACK_NATIVE_CADDY_BINARY, SHA256 pin and explicit HTTPS port; values omitted." - ); + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native HTTPS requires an absolute HACK_NATIVE_CADDY_BINARY, SHA256 pin and explicit HTTPS port; values omitted.", + }); } } export function parseNativeHttpsSelection( @@ -127,9 +130,11 @@ export function parseNativeHttpsSelection( port === undefined || String(Number(port)) !== port ) { - throw new Error( - "Native HTTPS selection requires binary, SHA256 and port together; values omitted." - ); + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native HTTPS selection requires binary, SHA256 and port together; values omitted.", + }); } const selection = { caddyBinary: binary, @@ -165,7 +170,7 @@ async function acquireReadyHttps(opts: { BOOT_ID.test(pool.guest_boot_id) ) ) { - throw new Error( + throw new ClosedCliError( "Native HTTPS requires a verified running pool incarnation." ); } @@ -270,15 +275,45 @@ const DEFAULTS: Dependencies = { captureFinalization: captureNativeProjectFinalization, waitFinalization: waitNativeProjectFinalization, }; +/** Fixed text of the native foreground configuration refusal. */ +export const NATIVE_CONFIGURATION_REFUSAL = + "Native foreground up cannot admit this configuration: routing, host dependencies, builds or unsupported runtime settings require explicit native support; configuration was not dropped."; +/** Known configuration refusal: a stable code and a closed message, no stack. */ function refused(): Error { - return new Error( - "Native foreground up cannot admit this configuration: routing, host dependencies, builds or unsupported runtime settings require explicit native support; configuration was not dropped." + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: NATIVE_CONFIGURATION_REFUSAL, + }); +} + +/** + * A runtime reply or saved ownership record that does not match this start. + * This is an integrity or ownership refusal, not a configuration refusal: it + * is closed (no stack) and carries no configuration code. + */ +function unconfirmed(subject: string): Error { + return new ClosedCliError( + `Native foreground up could not confirm ${subject}; inspect owned state before retrying.` + ); +} + +/** Fixed classifications from reviewed-route verification; peer values are never echoed. */ +function httpsVerificationFailed(code: string): Error { + return new ClosedCliError( + `Native HTTPS verification failed (${code}); peer values omitted.` + ); +} + +/** Cancellation is not a configuration refusal: it carries no refusal code. */ +function canceled(): Error { + return new ClosedCliError( + "Native foreground up was canceled; values omitted." ); } function requireActiveStartup(signal: AbortSignal | undefined): void { if (signal?.aborted) { - throw refused(); + throw canceled(); } } export function prepareNativeProjectServices( @@ -307,6 +342,9 @@ export function prepareNativeProjectServices( } return result; } +/** Fixed text of the refusal when the project share was not granted. */ +export const NATIVE_SHARED_SOURCE_REFUSAL = + "Native foreground up requires HACK_NATIVE_SHARED_SOURCE=1 to share this exact project, including ignored files. Set it to grant that share and run the command again."; /** Keep image acquisition separate from graph admission and preserve cancellation between requests. */ async function pinNativeProjectImages(opts: { readonly runtime: NativeRuntimeSelection; @@ -318,7 +356,7 @@ async function pinNativeProjectImages(opts: { }): Promise { for (const [name, spec] of Object.entries(opts.specs)) { if (opts.signal.aborted) { - throw refused(); + throw canceled(); } const retainedImage = opts.retainedImages.get(name); if (retainedImage) { @@ -339,7 +377,7 @@ async function pinNativeProjectImages(opts: { typeof ensured.image_id !== "string" || !IMAGE.test(ensured.image_id) ) { - throw refused(); + throw unconfirmed("the runtime image selection"); } spec.image = ensured.image_id; } @@ -410,7 +448,7 @@ function authoritative( value.journal_incomplete !== false || !isRecord(value.receipt) ) { - throw refused(); + throw unconfirmed("the startup receipt"); } const receipt = value.receipt; if ( @@ -420,7 +458,7 @@ function authoritative( typeof receipt.owner !== "string" || !OWNER.test(receipt.owner) ) { - throw refused(); + throw unconfirmed("the startup receipt"); } return { run, owner: receipt.owner, namespace, planId }; } @@ -442,7 +480,7 @@ function redirectHostname( try { target = new URL(location, `https://${hostname}:${port}${route.path}`); } catch { - throw refused(); + throw httpsVerificationFailed("REDIRECT_TARGET_REFUSED"); } const reviewed = routes.find((entry) => entry.hostname === target.hostname); if ( @@ -457,9 +495,7 @@ function redirectHostname( reviewed.path !== route.path || target.pathname !== route.path ) { - throw new Error( - "Native HTTPS verification failed (REDIRECT_TARGET_REFUSED); peer values omitted." - ); + throw httpsVerificationFailed("REDIRECT_TARGET_REFUSED"); } return target.hostname; } @@ -492,22 +528,18 @@ export async function verifyHttpsRoutes( let hostname = route.hostname; while (true) { if (visited.has(hostname)) { - throw new Error( - "Native HTTPS verification failed (REDIRECT_CYCLE); peer values omitted." - ); + throw httpsVerificationFailed("REDIRECT_CYCLE"); } visited.add(hostname); const response = results.get(hostname); if (!(response && Number.isInteger(response.statusCode))) { - throw refused(); + throw httpsVerificationFailed("NO_RESPONSE"); } if (response.statusCode >= 200 && response.statusCode < 300) { break; } if (!(HTTPS_REDIRECTS.has(response.statusCode) && response.location)) { - throw new Error( - `Native HTTPS verification failed (HTTP_STATUS_${response.statusCode}); peer values omitted.` - ); + throw httpsVerificationFailed(`HTTP_STATUS_${response.statusCode}`); } hostname = redirectHostname( response.location, @@ -572,7 +604,7 @@ function cleanupUnconfirmed( startupFailure === undefined ? "" : ` Startup diagnostic: ${diagnostic ? `${diagnostic[1]} (${diagnostic[2]})` : "STARTUP_FAILURE (details omitted)"}.`; - return new Error( + return new ClosedCliError( `Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying.${startup}${nativeCode ? ` Native exit diagnostic: ${nativeCode}.` : ""}` ); } @@ -641,7 +673,7 @@ async function refusePendingFreshStart( load: typeof loadNativeRestartIntent ): Promise { if (!restore && (await load(scope))) { - throw new Error( + throw new ClosedCliError( "Native project has a pending restart; use hack restart to resume retained data." ); } @@ -665,7 +697,7 @@ function retainedStartupSelection(opts: { (opts.aws.profile !== run.aws?.profile || opts.aws.region !== run.aws?.region)) ) { - throw new Error( + throw new ClosedCliError( "Native up cannot change the retained run's environment, profiles or AWS profile; data was not replaced." ); } @@ -707,7 +739,9 @@ async function selectNativeStartup(opts: { opts.restore && JSON.stringify(opts.restore) !== JSON.stringify(retained) ) { - throw new Error("Native retained run mapping changed before restore."); + throw new ClosedCliError( + "Native retained run mapping changed before restore." + ); } const preflight = await preflightNativeRetainedStartup({ runtime: opts.runtime, @@ -730,7 +764,7 @@ async function selectNativeStartup(opts: { preflight.runtimePhase === "running" && !confirmedNativeRetainedGraph(observed, retained) ) { - throw new Error( + throw new ClosedCliError( "Native project already has an owned run mapping that is not safely stopped; inspect it before starting another run." ); } @@ -771,7 +805,7 @@ export function nativeRuntimeMetadataSelected(opts: { return undefined; } if (opts.restore && !opts.selection) { - throw new Error( + throw new ClosedCliError( "Native retained graph requires its runtime metadata selection; no hooks, runtime start or HTTPS owner were requested." ); } @@ -788,7 +822,9 @@ async function retireRemovedMapping(opts: { return; } if (opts.mapping.owner !== opts.owned.owner) { - throw refused(); + throw new ClosedCliError( + "Native run mapping is owned by another run; it was not removed. Inspect owned state before retrying." + ); } if ( isRecord(opts.final) && @@ -836,7 +872,7 @@ async function requireAcknowledgedRetainedFinalization(opts: { timeoutMs: 1, }); } catch { - throw new Error( + throw new ClosedCliError( "Native retained frontend finalization is unconfirmed or its ownership changed; no hooks, runtime start or HTTPS owner were requested." ); } @@ -879,9 +915,10 @@ export async function startNativeProject(opts: { const allowedHosts = validateNativeAllowedHosts(opts.allowedHosts); validateHttpsSelection(opts.https); if (!opts.sharedSource) { - throw new Error( - "Native foreground up requires HACK_NATIVE_SHARED_SOURCE=1 to share this exact project, including ignored files." - ); + throw new HackCliError({ + code: "E_PERMISSION", + message: NATIVE_SHARED_SOURCE_REFUSAL, + }); } const deps = { ...DEFAULTS, ...opts.dependencies }; await refusePendingFreshStart(opts.restore, opts.scope, deps.loadRestart); @@ -943,7 +980,7 @@ export async function startNativeProject(opts: { !opts.restore && input.effectiveEnvName !== selection.envName ) { - throw new Error( + throw new ClosedCliError( "Native retained environment selection changed; data was not replaced." ); } @@ -982,7 +1019,7 @@ export async function startNativeProject(opts: { artifactFile.size > 16 * 1024 * 1024 || artifactFile.size === 0 ) { - throw new Error( + throw new ClosedCliError( "Native foreground up requires the bundled hack-relay-guest artifact." ); } @@ -1209,7 +1246,7 @@ export async function startNativeProject(opts: { typeof plan.dependency_plan_id !== "string" || !SHA.test(plan.dependency_plan_id) ) { - throw refused(); + throw unconfirmed("the host dependency plan"); } const invokeInspect = () => inspectNativeProjectGraph({ @@ -1265,7 +1302,7 @@ export async function startNativeProject(opts: { restore?.planId ?? review.planId ); if (controller.signal.aborted) { - throw new Error( + throw new ClosedCliError( "Native startup canceled before mapping publication." ); } @@ -1309,7 +1346,7 @@ export async function startNativeProject(opts: { }); void https.exited.then(() => { if (!httpsClosing) { - httpsFailure = new Error( + httpsFailure = new ClosedCliError( "Native HTTPS owner exited unexpectedly; graph shutdown requested." ); controller.abort(); diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 7d9e8a9b..ac456115 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -1,5 +1,5 @@ import { isAbsolute } from "node:path"; -import { ClosedCliError } from "../lib/cli-result.ts"; +import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { nativeAuthoredExecOptions } from "./native-authored-exec-options.ts"; import { @@ -62,9 +62,24 @@ export const NATIVE_RUNTIME_NOT_RUNNING = "runtime_not_running"; export function isNativeRuntimeNotRunning(error: unknown): boolean { return ( error instanceof NativeRuntimeRequestError && - (error.nativeCode === NATIVE_RUNTIME_NOT_RUNNING || - (error.nativeCode === "native_graph_foreground" && - error.nativeCauseCode === NATIVE_RUNTIME_NOT_RUNNING)) + nativeCodesReportRuntimeNotRunning(error) + ); +} + +/** + * True when a bounded native code pair reports a stopped runtime, either + * directly or wrapped by authored foreground control (see + * {@link isNativeRuntimeNotRunning}). Callers that carry the codes without the + * request error, such as a failed foreground start, classify with this. + */ +export function nativeCodesReportRuntimeNotRunning(codes: { + readonly nativeCode?: string; + readonly nativeCauseCode?: string; +}): boolean { + return ( + codes.nativeCode === NATIVE_RUNTIME_NOT_RUNNING || + (codes.nativeCode === "native_graph_foreground" && + codes.nativeCauseCode === NATIVE_RUNTIME_NOT_RUNNING) ); } @@ -75,6 +90,8 @@ export interface NativeRuntimeSelection { const MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const MAX_PRIVATE_INPUT_BYTES = 256 * 1024; +const REQUEST_BUDGET_REFUSAL = + "Native runtime request exceeds its input or time budget."; const SYSTEM_ENVIRONMENT = ["PATH", "HOME", "TMPDIR", "USER", "LOGNAME"]; /** Explicit candidate selection never redirects installed v4 or starts Docker. */ @@ -87,7 +104,7 @@ export function resolveNativeRuntimeSelection( const binary = env.HACK_NATIVE_BINARY; const home = env.HACK_NATIVE_HOME; if (!(binary && home && isAbsolute(binary) && isAbsolute(home))) { - throw new Error( + throw new ClosedCliError( "Native runtime requires absolute HACK_NATIVE_BINARY and HACK_NATIVE_HOME paths." ); } @@ -136,14 +153,23 @@ export async function invokeNativeRuntime(opts: { opts.args, bound, opts.privateInput !== undefined - ) && - Number.isSafeInteger(timeoutMs) - ) || + ) + ) + ) { + // Internal invariant: callers build these argv shapes; no user input selects them. + throw new Error(REQUEST_BUDGET_REFUSAL); + } + if ( + !Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 600_000 || (opts.privateInput?.byteLength ?? 0) > MAX_PRIVATE_INPUT_BYTES ) { - throw new Error("Native runtime request exceeds its input or time budget."); + // A selected startup timeout or the managed environment size can exceed these bounds. + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: REQUEST_BUDGET_REFUSAL, + }); } const env: Record = {}; for (const key of SYSTEM_ENVIRONMENT) { @@ -439,7 +465,9 @@ function completionResponse( try { return JSON.parse(new TextDecoder().decode(bytes)) as unknown; } catch { - throw new Error("Native runtime returned an invalid control response."); + throw new ClosedCliError( + "Native runtime returned an invalid control response." + ); } } @@ -478,7 +506,7 @@ function parseExecCompletion(bytes: Uint8Array, code: number): unknown { ) { return execResult; } - throw new Error( + throw new ClosedCliError( "Native exec returned an invalid or inconsistent completion response; command effects may have occurred." ); } @@ -505,7 +533,7 @@ async function readBoundedOutput( } total += chunk.byteLength; if (total > MAX_OUTPUT_BYTES) { - throw new Error( + throw new ClosedCliError( "Native runtime control response exceeds its output budget." ); } @@ -608,6 +636,20 @@ export async function readNativeFailureCode( return (await readNativeFailure(stream, signal))?.code; } +/** Like {@link readNativeFailureCode}, plus the bounded cause code it allows. */ +export async function readNativeFailureCodes( + stream: ReadableStream, + signal?: AbortSignal +): Promise<{ readonly code: string; readonly causeCode?: string } | undefined> { + const failure = await readNativeFailure(stream, signal); + return failure === undefined + ? undefined + : { + code: failure.code, + ...(failure.causeCode ? { causeCode: failure.causeCode } : {}), + }; +} + /** Capture delivery failure while the caller drains and reaps the receiver, never replaying input. */ export async function writeNativePrivateInput( sink: Pick | number | null | undefined, diff --git a/src/backends/native-runtime-metadata.ts b/src/backends/native-runtime-metadata.ts index e110da36..5b0559e3 100644 --- a/src/backends/native-runtime-metadata.ts +++ b/src/backends/native-runtime-metadata.ts @@ -1,3 +1,4 @@ +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; import { buildRuntimeHostMetadataEnvironments, @@ -28,9 +29,11 @@ export type NativeRuntimeMetadata = { }; function refused(): Error { - return new Error( - "Native runtime metadata requires reviewable services whose route labels match the reviewed plan; no graph was started." - ); + return new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: + "Native runtime metadata requires reviewable services whose route labels match the reviewed plan; no graph was started.", + }); } /** Mirror native review's hostname normalization: trailing dot removed, lowercase. */ diff --git a/src/backends/native-runtime-preflight.ts b/src/backends/native-runtime-preflight.ts new file mode 100644 index 00000000..3ec0fbfc --- /dev/null +++ b/src/backends/native-runtime-preflight.ts @@ -0,0 +1,64 @@ +import { isRecord } from "../lib/guards.ts"; +import { + invokeNativeRuntime, + isNativeRuntimeNotRunning, + NATIVE_RUNTIME_NOT_RUNNING, + NativeRuntimeRequestError, + type NativeRuntimeSelection, +} from "./native-runtime-client.ts"; + +/** + * True when a `runtime status --json` reply reports a runtime that cannot + * serve a graph: any phase other than `running` (for example `stopped`, + * `uninitialized` or `process-exited`) or a dead runtime process. A reply + * without these fields is inconclusive and reports false. + */ +export function nativeRuntimeStatusStopped(status: unknown): boolean { + return ( + isRecord(status) && + ((typeof status.phase === "string" && status.phase !== "running") || + status.process_alive === false) + ); +} + +/** + * Read-only check, before a foreground start reserves its startup intent, that + * the candidate runtime is running. A stopped runtime refuses with the same + * closed `runtime_not_running` code the runtime itself reports, so the start + * leaves no retained intent behind (and `ps` keeps reporting `not_started`). + * + * This is advisory and racy by nature: the runtime may stop right after the + * check, and the start then fails and retains its evidence as before. A failed + * or malformed status request is inconclusive and does not refuse; the start's + * own runtime requests report any real failure. + */ +export async function refuseStoppedNativeRuntime(opts: { + readonly runtime: NativeRuntimeSelection; + readonly cwd: string; + readonly timeoutMs: number; + readonly signal?: AbortSignal; + readonly invoke?: typeof invokeNativeRuntime; +}): Promise { + let status: unknown; + try { + status = await (opts.invoke ?? invokeNativeRuntime)({ + runtime: opts.runtime, + cwd: opts.cwd, + args: ["runtime", "status", "--json"], + timeoutMs: opts.timeoutMs, + signal: opts.signal, + }); + } catch (error: unknown) { + if (isNativeRuntimeNotRunning(error)) { + throw error; + } + return; + } + if (nativeRuntimeStatusStopped(status)) { + throw new NativeRuntimeRequestError({ + message: + "Native runtime is not running; no startup intent was reserved and nothing was started.", + nativeCode: NATIVE_RUNTIME_NOT_RUNNING, + }); + } +} diff --git a/src/backends/native-workspace-cache.ts b/src/backends/native-workspace-cache.ts index 8b2c79c4..e1c9d023 100644 --- a/src/backends/native-workspace-cache.ts +++ b/src/backends/native-workspace-cache.ts @@ -1,10 +1,14 @@ +import { HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; -function refused(): Error { - return new Error( - "Native workspace cache layout conflicts with declared mounts or is invalid; values omitted." - ); +/** The private adaptation's cache layout did not validate: a closed `E_CONFIG_INVALID` refusal. */ +function refused(): HackCliError { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Native workspace cache layout conflicts with declared mounts or is invalid; values omitted.", + }); } function mount(value: unknown): { source: string; diff --git a/src/cli/command.ts b/src/cli/command.ts index d12b833f..1dc48c43 100644 --- a/src/cli/command.ts +++ b/src/cli/command.ts @@ -34,6 +34,16 @@ export interface PositionalSpec { readonly required?: boolean; readonly multiple?: boolean; readonly description?: string; + /** + * A child command's argv starts at this positional. From its first token on, + * every token belongs to the child, including flags such as `--version`, + * `--help` or `--json`; Hack's own options must come before it. A `--` before + * the child argv is still accepted and is not passed on, while a `--` inside + * the child argv is passed through unchanged. Only the last positional of a + * command may set this. Without `multiple`, the child command is one quoted + * argument, and any further token is refused with a hint to quote it. + */ + readonly passthrough?: boolean; } export interface CliContext { @@ -224,7 +234,16 @@ export function parseCliArgv( argv: readonly string[], opts?: { readonly allowUnknownOptions?: boolean } ): ParsedCliInvocation { - const normalizedArgv = normalizeArgvForDefaults(cli, argv); + const childStart = findPassthroughStart(cli, argv); + const normalizedArgv = + childStart === null + ? normalizeArgvForDefaults(cli, argv) + : [ + ...normalizeArgvForDefaults(cli, argv.slice(0, childStart)), + // End Hack's option parsing: the child argv is passed on verbatim. + "--", + ...argv.slice(childStart), + ]; const options = buildUnionParseOptions(cli); let parsed: unknown; @@ -298,6 +317,130 @@ function normalizeArgvForDefaults( return out; } +/** + * Index of the first child-command token for a command whose positional is + * marked {@link PositionalSpec.passthrough}, or null when there is none or an + * explicit `--` already ends Hack's options before it. + * + * The scan mirrors the parser: it resolves the command path from leading + * positionals and skips the value token of each value-taking option, including + * the omitted-value form of options with a default. Without this boundary, + * `hack run web bun --version` would let Hack's own `--version` (or `--help`, + * `--json`, `--path`...) consume a flag meant for the child. + */ +export function findPassthroughStart( + cli: CliSpec, + argv: readonly string[] +): number | null { + const valueOptions = collectValueOptions(cli); + let subcommands: readonly AnyCommandSpec[] = cli.commands; + let command: AnyCommandSpec | null = null; + let resolving = true; + let positionalIndex = 0; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i] ?? ""; + if (token === "--") { + return null; + } + if (token.startsWith("-") && token !== "-") { + if (consumesNextToken({ token, nextToken: argv[i + 1], valueOptions })) { + i += 1; + } + continue; + } + const next = resolving + ? subcommands.find((c) => c.name === token) + : undefined; + if (next) { + command = next; + subcommands = next.subcommands; + continue; + } + resolving = false; + const start = passthroughPositionalIndex(command); + if (start === null) { + return null; + } + if (positionalIndex === start) { + return i; + } + positionalIndex += 1; + } + return null; +} + +/** + * The leading argv tokens that belong to Hack: everything before the child + * command of a passthrough positional and before the first `--`. Raw-argv + * checks (for example whether `--json` selects Hack's error envelope) must + * read only these, so a child's own `--json` never changes Hack's output. + */ +export function hackOwnedArgv( + cli: CliSpec, + argv: readonly string[] +): readonly string[] { + const separator = argv.indexOf("--"); + const childStart = findPassthroughStart(cli, argv); + const end = Math.min( + separator === -1 ? argv.length : separator, + childStart ?? argv.length + ); + return argv.slice(0, end); +} + +/** Every value-taking option, by long and short spelling. */ +function collectValueOptions(cli: CliSpec): ReadonlyMap { + const valueOptions = new Map(); + for (const opt of [...builtinOptions(), ...collectAllOptions(cli)]) { + if (opt.type === "boolean") { + continue; + } + valueOptions.set(opt.long, opt); + if (opt.short) { + valueOptions.set(opt.short, opt); + } + } + return valueOptions; +} + +/** True when an option token takes the following token as its value. */ +function consumesNextToken(opts: { + readonly token: string; + readonly nextToken: string | undefined; + readonly valueOptions: ReadonlyMap; +}): boolean { + const opt = opts.token.includes("=") + ? undefined + : opts.valueOptions.get(opts.token); + return ( + opt !== undefined && + !omitsOptionValue({ token: opts.token, opt, nextToken: opts.nextToken }) + ); +} + +/** A long option with a default takes no value token when none follows. */ +function omitsOptionValue(opts: { + readonly token: string; + readonly opt: OptionSpec; + readonly nextToken: string | undefined; +}): boolean { + return ( + opts.token.startsWith("--") && + isDefaultableOption(opts.opt) && + shouldApplyDefault({ opt: opts.opt, nextToken: opts.nextToken }) + ); +} + +function passthroughPositionalIndex( + command: AnyCommandSpec | null +): number | null { + if (!command) { + return null; + } + const index = command.positionals.findIndex((p) => p.passthrough === true); + return index === -1 ? null : index; +} + function isDefaultableOption(opt: OptionSpec): boolean { return opt.type !== "boolean" && Boolean(opt.defaultValue); } @@ -449,6 +592,15 @@ export function parsePositionalsForCommand< if (idx < remaining.length) { const extra = remaining.slice(idx).join(" "); + const single = posSpecs.at(-1); + if (single?.passthrough === true && single.multiple !== true) { + // A single-valued child command (such as `session exec`'s ) + // takes the whole command line as one quoted argument. + const whole = [out[single.name], ...remaining.slice(idx)].join(" "); + throw new CliUsageError( + `Unexpected arguments: ${extra}. <${single.name}> is one argument; quote the whole command, as in ${JSON.stringify(whole)}.` + ); + } throw new CliUsageError(`Unexpected arguments: ${extra}`); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 986e35c6..b497c565 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -275,8 +275,15 @@ function renderCommandHelp( lines.push(`${invocation} — ${command.summary}`); lines.push(""); lines.push("Usage:"); - lines.push(` ${renderCommandUsage(invocation, command)}`); + lines.push( + ` ${renderCommandUsage(invocation, command, [cli.name, ...matchedPath].join(" "))}` + ); lines.push(""); + const passthroughNote = renderPassthroughNote(command); + if (passthroughNote) { + lines.push(passthroughNote); + lines.push(""); + } if (command.group === "Beta") { lines.push("Status: Unsupported experimental workflow"); @@ -329,9 +336,20 @@ function renderCommandHelpMarkdown( lines.push("### Usage"); lines.push(""); lines.push("```bash"); - lines.push(renderCommandUsage(invocation, command)); + lines.push( + renderCommandUsage( + invocation, + command, + [cli.name, ...matchedPath].join(" ") + ) + ); lines.push("```"); lines.push(""); + const passthroughNote = renderPassthroughNote(command); + if (passthroughNote) { + lines.push(mdEscape(passthroughNote)); + lines.push(""); + } if (command.group === "Beta") { lines.push("> Status: Unsupported experimental workflow."); @@ -379,14 +397,38 @@ function renderCommandHelpMarkdown( function renderCommandUsage( invocation: string, - command: AnyCommandSpec + command: AnyCommandSpec, + base: string ): string { const hasSubcommands = command.subcommands.length > 0; - return hasSubcommands - ? `${invocation} [options]` + if (hasSubcommands) { + return `${invocation} [options]`; + } + const args = renderArgsFromPositionals(command.positionals); + // Options must precede a child command: everything after it is the child's. + return passthroughPositionalName(command) && args + ? `${base} [options] ${args}` : `${invocation} [options]`; } +function passthroughPositionalName( + command: AnyCommandSpec +): string | undefined { + return command.positionals.find((p) => p.passthrough === true)?.name; +} + +/** One line explaining where a child command's own argv begins. */ +function renderPassthroughNote(command: AnyCommandSpec): string | undefined { + const spec = command.positionals.find((p) => p.passthrough === true); + if (!spec) { + return; + } + if (spec.multiple !== true) { + return `\`<${spec.name}>\` is one argument: quote a command that has its own arguments or flags, as in \`"npm --version"\`. Put Hack options before it; a leading \`--\` is optional.`; + } + return `Everything from \`<${spec.name}>\` on is passed to the command unchanged, including flags such as \`--version\` or \`--help\`. Put Hack options before it; a leading \`--\` is optional.`; +} + function buildInvocation( cliName: string, path: readonly string[], diff --git a/src/cli/run.ts b/src/cli/run.ts index cf2d4eaa..375d8377 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -13,6 +13,7 @@ import { CliUsageError, collectAllowedOptionNames, collectUnionOptionNames, + hackOwnedArgv, hasHandler, parseCliArgv, parseOptionsForCommand, @@ -31,7 +32,11 @@ function isTruthyEnv(value: string | undefined): boolean { } export async function runCli(argv: readonly string[]): Promise { - const jsonRequested = argv.includes("--json"); + // Only Hack's own argv selects the JSON error envelope: a child command's + // `--json` (as in `hack run web tool --json`) belongs to the child. + const jsonRequested = hackOwnedArgv(CLI_SPEC, argv).includes("--json"); + // Set once argument parsing succeeded and the handler owns the request. + let handlerPath: readonly string[] | undefined; try { const cli = CLI_SPEC; const allowUnknownOptions = shouldAllowUnknownOptions({ @@ -102,6 +107,7 @@ export async function runCli(argv: readonly string[]): Promise { jsonRequested: parsed.values.json === true, }); + handlerPath = resolved.path.map((command) => command.name); return await resolved.command.handler({ ctx: { cwd: process.cwd(), cli }, args: { @@ -117,7 +123,7 @@ export async function runCli(argv: readonly string[]): Promise { process.stderr.write(`${sensitiveError}\n`); return 1; } - return await handleRunCliError({ error, jsonRequested }); + return await handleRunCliError({ error, jsonRequested, handlerPath }); } } @@ -143,10 +149,17 @@ function resolveSensitiveOutputError( * Without `--json`, known errors ({@link HackCliError}, {@link ClosedCliError}) * print their closed message only, plus the stack when `HACK_DEBUG=1`. Any * other exception is unexpected and always prints its stack. + * + * A usage error found while parsing arguments prints the help, since the + * arguments themselves were wrong. Once a command handler owns the request + * (`handlerPath` is set), a {@link CliUsageError} it throws is a refusal of a + * well-formed request: it prints the message and a one-line pointer to the + * command's help, never the full help. */ export async function handleRunCliError(opts: { readonly error: unknown; readonly jsonRequested: boolean; + readonly handlerPath?: readonly string[]; }): Promise { const { error, jsonRequested } = opts; if (error instanceof CliUsageError) { @@ -159,7 +172,11 @@ export async function handleRunCliError(opts: { return 1; } logger.error({ message: error.message }); - await printHelpForPath(CLI_SPEC, []); + if (opts.handlerPath === undefined) { + await printHelpForPath(CLI_SPEC, []); + } else { + process.stderr.write(`${usageHint(opts.handlerPath)}\n`); + } return 1; } @@ -191,6 +208,11 @@ export async function handleRunCliError(opts: { return 1; } +/** One-line pointer to a command's help, printed after a handler refusal. */ +export function usageHint(path: readonly string[]): string { + return `See \`${["hack", ...path].join(" ")} --help\` for usage.`; +} + /** Known errors show their stack only on request (`HACK_DEBUG=1`). */ function logDebugStack(error: Error): void { if (isTruthyEnv(process.env.HACK_DEBUG) && error.stack) { diff --git a/src/commands/dispatch.ts b/src/commands/dispatch.ts index 759bab92..a6873834 100644 --- a/src/commands/dispatch.ts +++ b/src/commands/dispatch.ts @@ -148,7 +148,7 @@ const runOptions = [ ] as const; const runPositionals = [ - { name: "command", required: false, multiple: true }, + { name: "command", required: false, multiple: true, passthrough: true }, ] as const; const statusOptions = [optJson] as const; diff --git a/src/commands/env.ts b/src/commands/env.ts index bcef1651..570b02dc 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -314,7 +314,9 @@ const execSpec = defineCommand({ optHostTimeout, optHostLifetime, ], - positionals: [{ name: "command", required: false, multiple: true }], + positionals: [ + { name: "command", required: false, multiple: true, passthrough: true }, + ], subcommands: [], } as const); @@ -345,7 +347,9 @@ const hostExecSpec = defineCommand({ optHostTimeout, optHostLifetime, ], - positionals: [{ name: "command", required: false, multiple: true }], + positionals: [ + { name: "command", required: false, multiple: true, passthrough: true }, + ], subcommands: [], } as const); diff --git a/src/commands/project.ts b/src/commands/project.ts index 7512381f..9ac23931 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -222,6 +222,7 @@ import { createNativeEnvFingerprintEntry, createNativeEnvFingerprintSession, } from "../lib/native-env-fingerprint-state.ts"; +import { refuseUnsupportedNativeUpOptions } from "../lib/native-up-refusals.ts"; import { readNodesRegistry } from "../lib/nodes-registry.ts"; import { parseOpenHostPreference, @@ -507,7 +508,7 @@ const runOptions = [ ] as const; const runPositionals = [ { name: "service", required: true }, - { name: "cmd", required: false, multiple: true }, + { name: "cmd", required: false, multiple: true, passthrough: true }, ] as const; const optLoki = defineOption({ name: "loki", @@ -4919,16 +4920,13 @@ async function handleNativeUp({ ReturnType >; }): Promise { - if ( - args.options.detach || - args.options.json || - args.options.target || - args.positionals.services?.length - ) { - throw new CliUsageError( - "Native up currently supports foreground whole-project startup only; detach, JSON, target and service selection are unavailable." - ); - } + refuseUnsupportedNativeUpOptions({ + restart, + detach: args.options.detach, + json: args.options.json, + target: args.options.target, + services: args.positionals.services, + }); const project = await resolveProjectForArgs({ ctx, pathOpt: args.options.path, @@ -5004,9 +5002,10 @@ async function handleNativeUp({ const cleanup = adoptNativeLifecycleCleanup(lifecycle); if (lifecycle.code !== 0) { await cleanup(); - throw new Error( - "Native up lifecycle preparation failed; values omitted." - ); + throw new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: "Native up lifecycle preparation failed; values omitted.", + }); } return { cleanup, @@ -5020,9 +5019,11 @@ async function handleNativeUp({ composeProject, }); if (code !== 0) { - throw new Error( - "Native up lifecycle readiness hook failed; values omitted." - ); + throw new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: + "Native up lifecycle readiness hook failed; values omitted.", + }); } }, }; @@ -5036,9 +5037,11 @@ async function handleNativeUp({ } } if (!startup.sharedSource) { - throw new CliUsageError( - "Native restart requires HACK_NATIVE_SHARED_SOURCE=1 before cleanup." - ); + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Native restart requires HACK_NATIVE_SHARED_SOURCE=1 before cleanup; nothing was stopped. Set it to grant the project share and run the command again.", + }); } try { return await restartNativeImageProject({ @@ -5168,9 +5171,11 @@ async function restartNativeImageProject({ }, }); if (code !== 0) { - throw new Error( - "Native restart cleanup failed; retained data was not replaced." - ); + throw new HackCliError({ + code: "E_LIFECYCLE_FAILED", + message: + "Native restart cleanup failed; retained data was not replaced.", + }); } }, start: ({ run, onReady }) => diff --git a/src/commands/session.ts b/src/commands/session.ts index d300eac4..4a743c4c 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -204,6 +204,7 @@ const execSpec = defineCommand({ name: "command", description: "Command to execute in workspace", required: true, + passthrough: true, }, ], subcommands: [], diff --git a/src/commands/x.ts b/src/commands/x.ts index 4a3fa044..e1a0032d 100644 --- a/src/commands/x.ts +++ b/src/commands/x.ts @@ -26,7 +26,9 @@ const xSpec = defineCommand({ ].join("\n"), group: "Extensions", options: [], - positionals: [{ name: "args", required: false, multiple: true }], + positionals: [ + { name: "args", required: false, multiple: true, passthrough: true }, + ], subcommands: [], } as const); diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 6690afa8..226b89c0 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -33,6 +33,8 @@ import { resolveNativeAuthoredExecStaleEnvWarning } from "../backends/native-exe import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; import { isNativeRuntimeNotRunning, + NATIVE_RUNTIME_NOT_RUNNING, + nativeCodesReportRuntimeNotRunning, resolveNativeRuntimeSelection, } from "../backends/native-runtime-client.ts"; import { CliUsageError } from "../cli/command.ts"; @@ -47,6 +49,7 @@ import { createNativeEnvFingerprintSession, type NativeEnvFingerprintSession, } from "./native-env-fingerprint-state.ts"; +import { nativeForegroundDetachRefusal } from "./native-up-refusals.ts"; function unsupported(): never { throw new HackCliError({ @@ -184,6 +187,17 @@ function assertRecoveryOptions(options: NativeComposeCommandOptions): void { } function assertForegroundOptions(options: NativeComposeCommandOptions): void { + // Where authored foreground up/restart is supported, a detached start gets + // the same one-line refusal as an image project, not the generic one. + if ( + process.platform === "darwin" && + options.detach && + (options.operation === "up" || options.operation === "restart") + ) { + throw nativeForegroundDetachRefusal({ + restart: options.operation === "restart", + }); + } if ( process.platform !== "darwin" || !["up", "down", "restart"].includes(options.operation) || @@ -215,6 +229,9 @@ function startupFailure(error: unknown): number { ) { return 130; } + if (nativeCodesReportRuntimeNotRunning(error)) { + throw stoppedRuntimeStartup(error); + } throw new HackCliError({ code: error.nativeCode === "native_graph_subset" @@ -236,6 +253,40 @@ function startupFailure(error: unknown): number { }); } +/** + * A start refused because the candidate runtime is stopped reports that cause + * directly, as logs does, with the next action. When nothing was reserved + * (`not-started`), `ps` keeps reporting `not_started`. A start that reserved + * its intent before the runtime stopped keeps that evidence, so `ps` reports + * `pending` until the retained attempt is inspected. A start whose consumer + * never reserved runtime state (`unreserved`) had its intent retired, so `ps` + * reports `not_started`. + */ +function stoppedRuntimeStartup( + error: NativeAuthoredProjectStartError +): HackCliError { + const message = { + "not-started": + "Native runtime is not running; nothing was started. Start the native runtime, then run this command again.", + removed: + "Native runtime is not running; this start did not stay up and its native cleanup was confirmed. Start the native runtime, then run this command again.", + unreserved: + "Native runtime is not running; this start never reserved runtime state and its startup intent was retired, so `hack ps` reports not_started. Start the native runtime, then run this command again.", + retained: + "Native runtime is not running; this start did not reach Ready and its startup evidence is retained, so `hack ps` reports it as pending. Start the native runtime and inspect owned state before retrying.", + }[error.outcome]; + return new HackCliError({ + code: "E_DOCKER_UNAVAILABLE", + message, + detail: { + outcome: error.outcome, + canceled: error.canceled, + stage: error.stage, + nativeCode: NATIVE_RUNTIME_NOT_RUNNING, + }, + }); +} + /** Stop refusals keep the generation; start refusals keep the foreground up contract. */ function restartFailure(error: unknown): number { if ( @@ -303,7 +354,7 @@ const STATUS_NOTES: Readonly< > = { not_started: "Native project has not been started.", pending: - "Native project startup is pending or incomplete; no current service status is confirmed. If no startup is running, `hack down --recover` retires an attempt that never reserved runtime state.", + "Native project startup is pending: a start reserved its intent but has not reached Ready (it is still starting, or it failed and its startup evidence is retained). No current service status is confirmed. If no startup is running, `hack down --recover` retires an attempt that never reserved runtime state.", runtime_not_running: "Native runtime is not running; no services are running.", }; diff --git a/src/lib/native-up-refusals.ts b/src/lib/native-up-refusals.ts new file mode 100644 index 00000000..2bd7d93a --- /dev/null +++ b/src/lib/native-up-refusals.ts @@ -0,0 +1,54 @@ +import { HackCliError } from "./cli-result.ts"; + +/** + * Native image-project `up` and `restart` run one whole-project foreground + * owner. Each option that owner cannot honor is refused by name, with the + * supported form, as a closed `E_USAGE` error: the top-level handler prints the + * one-line message (no command help, no stack). The refusal happens before + * project discovery, input acquisition or any runtime request. + */ +/** + * The one-line `E_USAGE` refusal of a detached native foreground start. Image + * and authored projects share it, so `up --detach` and `restart --detach` give + * the same code and message on either path. + */ +export function nativeForegroundDetachRefusal(opts: { + readonly restart: boolean; +}): HackCliError { + const command = opts.restart ? "hack restart" : "hack up"; + return new HackCliError({ + code: "E_USAGE", + message: `Native \`${command}\` runs in the foreground only, so --detach is not supported. Run \`${command}\` without --detach in its own terminal and keep it open; use \`hack ps\` and \`hack logs\` from another terminal, and press Ctrl-C to stop it.`, + }); +} + +export function refuseUnsupportedNativeUpOptions(opts: { + readonly restart: boolean; + readonly detach?: boolean; + readonly json?: boolean; + readonly target?: string; + readonly services?: readonly string[]; +}): void { + const command = opts.restart ? "hack restart" : "hack up"; + const refuse = (message: string): never => { + throw new HackCliError({ code: "E_USAGE", message }); + }; + if (opts.detach) { + throw nativeForegroundDetachRefusal({ restart: opts.restart }); + } + if (opts.json) { + refuse( + `Native \`${command}\` does not support --json because it stays in the foreground until stopped. Run it without --json and use \`hack ps --json\` from another terminal for status.` + ); + } + if (opts.target) { + refuse( + `Native \`${command}\` does not support --target; it starts the selected project on this host. Run it without --target.` + ); + } + if (opts.services && opts.services.length > 0) { + refuse( + `Native \`${command}\` starts the whole project; service selection is not supported. Run it without service names.` + ); + } +} diff --git a/tests/cli-closed-error-output.test.ts b/tests/cli-closed-error-output.test.ts index 07b0ed03..a31432fa 100644 --- a/tests/cli-closed-error-output.test.ts +++ b/tests/cli-closed-error-output.test.ts @@ -1,7 +1,14 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createServer } from "node:net"; +import { checkNativeHttpsPort } from "../src/backends/native-https-port.ts"; +import { NativeHttpsStartupError } from "../src/backends/native-https-startup-failure.ts"; +import { nativeHttpsVerificationError } from "../src/backends/native-project-https.ts"; +import { NativePartialShutdownError } from "../src/backends/native-project-interrupted-cleanup.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { CliUsageError } from "../src/cli/command.ts"; import { handleRunCliError } from "../src/cli/run.ts"; import { ClosedCliError, HackCliError } from "../src/lib/cli-result.ts"; +import { refuseUnsupportedNativeUpOptions } from "../src/lib/native-up-refusals.ts"; import { setLoggerBackendOverride } from "../src/ui/logger.ts"; import { restoreEnv } from "./helpers/env.ts"; @@ -102,3 +109,141 @@ test("--json keeps one envelope for a closed refusal", async () => { error: { code: "E_UNEXPECTED", message: error.message }, }); }); + +test("a handler's usage refusal prints its message and one hint line, not the help", async () => { + const error = new CliUsageError( + "Native run uses the started project's environment and profiles; restart with the requested selections first." + ); + expect( + await handleRunCliError({ + error, + jsonRequested: false, + handlerPath: ["run"], + }) + ).toBe(1); + expect(output).toBe( + `ERROR: ${error.message}\nSee \`hack run --help\` for usage.\n` + ); +}); + +test("a usage error found while parsing still prints the help", async () => { + const error = new CliUsageError("Unknown option(s): --bogus"); + expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1); + expect(output).toStartWith(`ERROR: ${error.message}\n`); + expect(output).toContain("hack [options]"); +}); + +test("a handler's usage refusal under --json stays one E_USAGE envelope", async () => { + const error = new CliUsageError("Native exec does not support --env."); + expect( + await handleRunCliError({ + error, + jsonRequested: true, + handlerPath: ["exec"], + }) + ).toBe(1); + expect(JSON.parse(output)).toEqual({ + ok: false, + error: { code: "E_USAGE", message: error.message }, + }); +}); + +function thrownBy(action: () => void): unknown { + try { + action(); + } catch (error: unknown) { + return error; + } + return undefined; +} + +test.each([ + { + refusal: () => + refuseUnsupportedNativeUpOptions({ restart: false, detach: true }), + text: "E_USAGE: Native `hack up` runs in the foreground only, so --detach is not supported. Run `hack up` without --detach in its own terminal and keep it open;", + }, + { + refusal: () => + refuseUnsupportedNativeUpOptions({ restart: true, json: true }), + text: "E_USAGE: Native `hack restart` does not support --json", + }, + { + refusal: () => + refuseUnsupportedNativeUpOptions({ restart: false, target: "remote" }), + text: "E_USAGE: Native `hack up` does not support --target;", + }, + { + refusal: () => + refuseUnsupportedNativeUpOptions({ restart: false, services: ["web"] }), + text: "E_USAGE: Native `hack up` starts the whole project; service selection is not supported.", + }, +])("native up option refusal prints one closed line: $text", async ({ + refusal, + text, +}) => { + const error = thrownBy(refusal); + expect(error).toBeInstanceOf(HackCliError); + expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1); + expect(output).toStartWith(`ERROR: ${text}`); + expect(output.trimEnd().split("\n")).toHaveLength(1); + expect(output).not.toMatch(STACK_FRAME); +}); + +test("supported native up options are not refused", () => { + expect( + thrownBy(() => + refuseUnsupportedNativeUpOptions({ + restart: false, + detach: false, + json: false, + services: [], + }) + ) + ).toBeUndefined(); +}); + +test.each([ + () => new NativePartialShutdownError(), + () => new NativeHttpsStartupError("frontend-start"), +])("native refusal class %# prints its closed message without a stack", async (make) => { + const error = make(); + expect(error).toBeInstanceOf(ClosedCliError); + expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1); + expect(output).toBe(`ERROR: ${error.message}\n`); +}); + +/** + * HTTPS verification and port refusals are raised inside the foreground + * owner's readiness step, which a CLI spawn reaches only with a live serve + * owner. Their real errors still render as one closed line here. + */ +test("a native HTTPS verification failure prints one closed line", async () => { + const error = nativeHttpsVerificationError({ code: "ECONNREFUSED" }); + expect(error).toBeInstanceOf(ClosedCliError); + expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1); + expect(output).toBe( + "ERROR: Native HTTPS verification failed (ECONNREFUSED); peer values omitted.\n" + ); +}); + +test("an occupied native HTTPS port prints one coded line without the port", async () => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Fixture has no TCP address"); + } + const error = await checkNativeHttpsPort(address.port).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(HackCliError); + expect(await handleRunCliError({ error, jsonRequested: false })).toBe(1); + expect(output).toBe( + "ERROR: E_CONFIG_INVALID: The selected native HTTPS port is already in use; the existing listener was left untouched. Select a free port.\n" + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index ddf8d7e4..7b3c6121 100644 --- a/tests/cli-help.test.ts +++ b/tests/cli-help.test.ts @@ -49,3 +49,18 @@ test("dispatch help no longer advertises built-in GitHub PR automation", () => { expect(help).not.toContain("create/update GitHub PR"); expect(help).not.toMatch(/--pr(?:-|\b)|--github-profile/); }); + +test("text help says where a child command's own argv begins", () => { + const run = renderHelpForPath(CLI_SPEC, ["run"]); + expect(run).toContain("hack run [options] [cmd...]"); + expect(run).toContain( + "Everything from `` on is passed to the command unchanged, including flags such as `--version` or `--help`." + ); + // session exec takes its workspace command as one argument: the note says to + // quote it rather than claiming that later tokens pass through. + const sessionExec = renderHelpForPath(CLI_SPEC, ["session", "exec"]); + expect(sessionExec).toContain( + '`` is one argument: quote a command that has its own arguments or flags, as in `"npm --version"`.' + ); + expect(sessionExec).not.toContain("Everything from `` on"); +}); diff --git a/tests/cli-passthrough-argv.test.ts b/tests/cli-passthrough-argv.test.ts new file mode 100644 index 00000000..623429e1 --- /dev/null +++ b/tests/cli-passthrough-argv.test.ts @@ -0,0 +1,287 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + findPassthroughStart, + hackOwnedArgv, + parseCliArgv, + parsePositionalsForCommand, + resolveCommand, +} from "../src/cli/command.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * Flags after a child command belong to the child. Hack's own `--version`, + * `--help`, `--json` and every other Hack option must not consume them, with + * or without an explicit `--`. + */ + +function parse(argv: readonly string[]) { + const parsed = parseCliArgv(CLI_SPEC, argv); + const resolved = resolveCommand(CLI_SPEC, parsed.positionals); + return { + values: parsed.values, + path: resolved.path.map((command) => command.name), + rest: resolved.remainingPositionals, + }; +} + +const CHILD_FLAGS = [ + ["--version"], + ["-v"], + ["--help"], + ["-h"], + ["--json"], + ["--branch", "other"], + ["--path", "/elsewhere"], + ["--frozen-lockfile", "--watch=false", "-x"], +] as const; + +const PASSTHROUGH = [ + { prefix: ["run", "--profile", "toolchain", "web"], path: ["run"] }, + { prefix: ["exec", "web"], path: ["exec"] }, + { prefix: ["host", "exec"], path: ["host", "exec"] }, + { prefix: ["env", "exec"], path: ["env", "exec"] }, + { prefix: ["x"], path: ["x"] }, + // Beta and unsupported, but still shipped: its child command is passthrough too. + { prefix: ["dispatch", "run", "--project", "p"], path: ["dispatch", "run"] }, +] as const; + +for (const { prefix, path } of PASSTHROUGH) { + test.each( + CHILD_FLAGS.map((flags) => ({ flags: [...flags] })) + )(`${path.join(" ")} passes $flags after the child command to the child`, ({ + flags, + }) => { + const child = ["bun", ...flags]; + for (const explicit of [false, true]) { + const argv = [...prefix, ...(explicit ? ["--"] : []), ...child]; + const parsed = parse(argv); + expect(parsed.path).toEqual([...path]); + expect(parsed.values.version).toBeUndefined(); + expect(parsed.values.help).toBeUndefined(); + expect(parsed.values.json).toBeUndefined(); + expect(parsed.values.branch).toBeUndefined(); + expect(parsed.values.path).toBeUndefined(); + expect(parsed.rest.slice(-child.length)).toEqual(child); + } + }); +} + +test("hack options before the child command still belong to hack", () => { + const parsed = parse([ + "run", + "--path", + "/project", + "web", + "--workdir", + "/app", + "--profile", + "toolchain", + "bun", + "--version", + ]); + expect(parsed.values).toEqual({ + path: "/project", + workdir: "/app", + profile: "toolchain", + }); + expect(parsed.rest).toEqual(["web", "bun", "--version"]); +}); + +test("a `--` inside the child argv reaches the child unchanged", () => { + expect(parse(["run", "web", "npm", "test", "--", "--watch"]).rest).toEqual([ + "web", + "npm", + "test", + "--", + "--watch", + ]); + // A leading `--` still ends Hack's options and is not passed on. + expect( + parse(["run", "web", "--", "npm", "test", "--", "--watch"]).rest + ).toEqual(["web", "npm", "test", "--", "--watch"]); +}); + +test("session exec never gives a flag after the workspace command to Hack", () => { + const parsed = parse(["session", "exec", "dev", "npm", "--version"]); + expect(parsed.path).toEqual(["session", "exec"]); + expect(parsed.values.version).toBeUndefined(); + // The command is one argument, so the extra token is then a usage error + // rather than Hack printing its own version. + expect(parsed.rest).toEqual(["dev", "npm", "--version"]); +}); + +test("an unquoted single-argument child command refuses with a quoting hint", () => { + const resolved = resolveCommand(CLI_SPEC, ["session", "exec"]); + const positionals = resolved.command?.positionals ?? []; + expect(() => + parsePositionalsForCommand(positionals, ["dev", "npm", "--version"]) + ).toThrow( + 'Unexpected arguments: --version. is one argument; quote the whole command, as in "npm --version".' + ); + const quoted: Readonly> = parsePositionalsForCommand( + positionals, + ["dev", "npm --version"] + ); + expect(quoted).toEqual({ workspace: "dev", command: "npm --version" }); +}); + +test("only Hack's own argv can select the JSON error envelope", () => { + expect(hackOwnedArgv(CLI_SPEC, ["run", "web", "tool", "--json"])).toEqual([ + "run", + "web", + ]); + expect( + hackOwnedArgv(CLI_SPEC, ["--json", "run", "web", "tool", "--json"]) + ).toEqual(["--json", "run", "web"]); + expect(hackOwnedArgv(CLI_SPEC, ["exec", "web", "cat", "--json"])).toEqual([ + "exec", + "web", + ]); + // Tokens after an explicit `--` are the child's too. + expect(hackOwnedArgv(CLI_SPEC, ["run", "web", "--", "--json"])).toEqual([ + "run", + "web", + ]); + // A command without a child command keeps every token. + expect(hackOwnedArgv(CLI_SPEC, ["up", "web", "--json"])).toEqual([ + "up", + "web", + "--json", + ]); +}); + +test("Hack's own flags still work before the child command", () => { + expect(parse(["--version"]).values.version).toBe(true); + expect(parse(["run", "--help"]).values.help).toBe(true); + // Before the first child token, `--version` is still Hack's. + expect(parse(["run", "web", "--version"]).values.version).toBe(true); + // Commands without a child command keep ordinary parsing. + expect(parse(["up", "web", "--json"]).values.json).toBe(true); + expect(findPassthroughStart(CLI_SPEC, ["up", "web", "--json"])).toBeNull(); +}); + +test("the boundary skips option values, including omitted defaulted values", () => { + expect( + findPassthroughStart(CLI_SPEC, ["run", "--workdir", "web", "svc", "ls"]) + ).toBe(4); + // `--tail` has a default: followed by a non-number it takes no value, so + // `web` is the service, exactly as the parser normalizes it. + expect( + findPassthroughStart(CLI_SPEC, ["run", "--tail", "web", "ls", "-la"]) + ).toBe(3); + expect( + findPassthroughStart(CLI_SPEC, ["run", "--workdir=/app", "web", "ls"]) + ).toBe(3); + expect( + findPassthroughStart(CLI_SPEC, ["run", "web", "--", "ls", "--all"]) + ).toBeNull(); +}); + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function cli(args: readonly string[]) { + const root = await realpath(await mkdtemp(join(tmpdir(), "hack-argv-"))); + roots.push(root); + const child = Bun.spawn([process.execPath, resolve("index.ts"), ...args], { + cwd: root, + env: privateCliEnv({ + root, + env: { HACK_NO_INTERACTIVE: "1", HACK_LOGGER: "console" }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, code }; +} + +const VERSION_LINE = /^hack v\d/m; +/** A source CLI spawn can take several seconds on a loaded runner. */ +const SPAWN_TIMEOUT_MS = 30_000; + +test.each([ + [["run", "web", "bun", "--version"]], + [["run", "--profile", "toolchain", "web", "bun", "--version"]], + [["run", "web", "--", "bun", "--version"]], + [["exec", "web", "bun", "--help"]], + [["host", "exec", "bun", "--version"]], + [["dispatch", "run", "--project", "p", "bun", "--version"]], +])( + "source CLI %j does not print Hack's version or help for a child flag", + async (args) => { + const result = await cli(args); + // No project here: the command itself refuses, but it got that far. + expect(result.code).not.toBe(0); + expect(result.stdout).not.toMatch(VERSION_LINE); + expect(result.stdout + result.stderr).not.toContain( + "hack [options]" + ); + }, + SPAWN_TIMEOUT_MS +); + +test.each([ + [["run", "web", "tool", "--json"]], + [["run", "--profile", "toolchain", "web", "tool", "--json"]], + [["exec", "web", "cat", "--json"]], +])( + "source CLI %j keeps Hack's refusal human-readable for a child --json", + async (args) => { + const result = await cli(args); + expect(result.code).not.toBe(0); + // The child's --json is not Hack's: no JSON error envelope on stdout. + expect(result.stdout).not.toContain('"ok":false'); + expect(result.stderr).toContain("ERROR:"); + }, + SPAWN_TIMEOUT_MS +); + +test( + "source CLI --json before the child command still selects the envelope", + async () => { + const result = await cli(["--json", "run", "web", "tool", "--json"]); + expect(result.code).not.toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ ok: false }); + }, + SPAWN_TIMEOUT_MS +); + +test( + "source CLI unquoted session exec command refuses with a quoting hint", + async () => { + const result = await cli(["session", "exec", "ws", "npm", "--version"]); + expect(result.code).toBe(1); + // Not Hack's bare `--version` output (the usage help's banner also names + // the version, so match the whole line). + expect(result.stdout).not.toMatch(/^hack v[\d.]+\S*$/m); + expect(result.stdout + result.stderr).toContain( + 'quote the whole command, as in "npm --version"' + ); + }, + SPAWN_TIMEOUT_MS +); + +test( + "source CLI --version alone still prints Hack's version", + async () => { + const result = await cli(["--version"]); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(VERSION_LINE); + }, + SPAWN_TIMEOUT_MS +); diff --git a/tests/helpers/closed-refusal.ts b/tests/helpers/closed-refusal.ts new file mode 100644 index 00000000..a4b40877 --- /dev/null +++ b/tests/helpers/closed-refusal.ts @@ -0,0 +1,75 @@ +import { expect } from "bun:test"; +import { + ClosedCliError, + HackCliError, + type HackErrorCode, +} from "../../src/lib/cli-result.ts"; + +async function rejection(promise: Promise): Promise { + return await promise.then( + () => undefined, + (caught: unknown) => caught + ); +} + +/** + * Awaits a rejection that must be a known, value-free refusal: a + * {@link ClosedCliError}, which the CLI prints as its message alone, without a + * stack. A plain `Error` with the same text fails, so these assertions catch a + * refusal that regresses to an unexpected exception. + */ +export async function expectClosedRefusal( + promise: Promise, + text: string +): Promise { + const error = await rejection(promise); + expect(error).toBeInstanceOf(ClosedCliError); + expect(error).toHaveProperty("message", expect.stringContaining(text)); + return error as ClosedCliError; +} + +function thrown(action: () => unknown): unknown { + try { + action(); + } catch (caught: unknown) { + return caught; + } + return undefined; +} + +/** Synchronous form of {@link expectClosedRefusal}. */ +export function expectClosedThrow( + action: () => unknown, + text: string +): ClosedCliError { + const error = thrown(action); + expect(error).toBeInstanceOf(ClosedCliError); + expect(error).toHaveProperty("message", expect.stringContaining(text)); + return error as ClosedCliError; +} + +/** Synchronous form of {@link expectCodedRefusal}. */ +export function expectCodedThrow( + action: () => unknown, + code: HackErrorCode, + text: string +): HackCliError { + const error = thrown(action); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toHaveProperty("code", code); + expect(error).toHaveProperty("message", expect.stringContaining(text)); + return error as HackCliError; +} + +/** Like {@link expectClosedRefusal}, for a refusal with a stable `--json` code. */ +export async function expectCodedRefusal( + promise: Promise, + code: HackErrorCode, + text: string +): Promise { + const error = await rejection(promise); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toHaveProperty("code", code); + expect(error).toHaveProperty("message", expect.stringContaining(text)); + return error as HackCliError; +} diff --git a/tests/native-authored-command-dispatch.test.ts b/tests/native-authored-command-dispatch.test.ts index 9637a6bd..30d1a6c6 100644 --- a/tests/native-authored-command-dispatch.test.ts +++ b/tests/native-authored-command-dispatch.test.ts @@ -102,7 +102,6 @@ async function invoke(opts: { } test.each([ - { args: ["up", "--detach"] }, { args: ["up", "--json"] }, { args: ["restart", "--json"] }, { args: ["restart", "web"] }, @@ -128,6 +127,30 @@ test.each([ ]); }); +test("source CLI native up --detach refuses with one line before compiler or runtime work", async () => { + const selected = await fixture(); + const value = await invoke({ + selected, + args: ["up", "--detach"], + backend: "native", + }); + expect(value.code).toBe(1); + // macOS supports authored foreground up, so a detached start gets the image + // project's E_USAGE refusal; elsewhere authored execution is unsupported. + expect(value.stdout + value.stderr).toContain( + process.platform === "darwin" + ? "E_USAGE: Native `hack up` runs in the foreground only, so --detach is not supported." + : "E_NATIVE_PROJECT_UNSUPPORTED" + ); + expect(value.stdout + value.stderr).not.toMatch(/^\s+at /m); + expect(await Bun.file(join(selected.root, "compiler-called")).exists()).toBe( + false + ); + expect(await readdir(join(selected.root, ".hack"))).toEqual([ + "hack.project.json", + ]); +}); + test("source CLI omitted backend retains the existing Compose foreground refusal", async () => { const selected = await fixture(); const value = await invoke({ selected, args: ["up"] }); diff --git a/tests/native-authored-command.test.ts b/tests/native-authored-command.test.ts index b491bef7..d589dfa0 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -12,6 +12,7 @@ import { CliUsageError } from "../src/cli/command.ts"; import { HackCliError } from "../src/lib/cli-result.ts"; import { tryNativeAuthoredCommand } from "../src/lib/native-authored-command.ts"; import type { NativeComposeCommandOptions } from "../src/lib/native-compose-command.ts"; +import { refuseUnsupportedNativeUpOptions } from "../src/lib/native-up-refusals.ts"; const roots: string[] = []; afterEach(async () => { @@ -140,8 +141,17 @@ test.each([ return Promise.resolve(); }, }).catch((error: unknown) => error); + // Where authored foreground starts are supported, a detached start gets the + // image project's one-line E_USAGE refusal instead of the generic one. + const detachedStart = + process.platform === "darwin" && + options.detach === true && + ["up", "restart"].includes(options.operation ?? "up"); expect(result).toBeInstanceOf(HackCliError); - expect(result).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect(result).toHaveProperty( + "code", + detachedStart ? "E_USAGE" : "E_NATIVE_PROJECT_UNSUPPORTED" + ); expect(calls).toBe(0); expect(await readdir(join(selected.root, ".hack"))).toEqual([]); }); @@ -570,6 +580,97 @@ macTest( } ); +macTest.each([ + { + outcome: "not-started" as const, + stage: "reservation" as const, + nativeCode: "runtime_not_running", + text: "Native runtime is not running; nothing was started. Start the native runtime, then run this command again.", + }, + { + outcome: "retained" as const, + stage: "reservation" as const, + nativeCode: "runtime_not_running", + text: "so `hack ps` reports it as pending", + }, + { + // Authored foreground control wraps a runtime that stopped during serve. + outcome: "retained" as const, + stage: "runtime" as const, + nativeCode: "native_graph_foreground", + nativeCauseCode: "runtime_not_running", + text: "so `hack ps` reports it as pending", + }, + { + // HACK-1272: the consumer refused before reserving a journal, so startup + // retired its intent; the stopped runtime is still the reported cause. + outcome: "unreserved" as const, + stage: "runtime" as const, + nativeCode: "runtime_not_running", + text: "its startup intent was retired, so `hack ps` reports not_started", + }, +])( + "a start refused by a stopped runtime ($outcome, $nativeCode) reports the runtime, not incomplete startup", + async ({ outcome, stage, nativeCode, nativeCauseCode, text }) => { + const selected = await fixture(); + const value = await tryNativeAuthoredCommand({ + ...selected, + serve: () => + Promise.reject( + new NativeAuthoredProjectStartError({ + outcome, + canceled: false, + nativeCode, + nativeCauseCode, + stage, + }) + ), + }).catch((caught: unknown) => caught); + expect(value).toBeInstanceOf(HackCliError); + expect(value).toHaveProperty("code", "E_DOCKER_UNAVAILABLE"); + expect(value).toHaveProperty("message", expect.stringContaining(text)); + expect(value).toHaveProperty("detail", { + outcome, + canceled: false, + stage, + nativeCode: "runtime_not_running", + }); + } +); + +macTest.each(["up", "restart"] as const)( + "a detached native %s gives the image project's one-line E_USAGE refusal", + async (operation) => { + const selected = await fixture(); + const value = await tryNativeAuthoredCommand({ + ...selected, + options: { ...selected.options, operation, detach: true }, + }).catch((caught: unknown) => caught); + // The image path refuses the same request before project discovery. + let image: unknown; + try { + refuseUnsupportedNativeUpOptions({ + restart: operation === "restart", + detach: true, + }); + } catch (caught: unknown) { + image = caught; + } + expect(image).toBeInstanceOf(HackCliError); + expect(value).toBeInstanceOf(HackCliError); + expect(value).toHaveProperty("code", "E_USAGE"); + expect(value).toHaveProperty("code", (image as HackCliError).code); + expect(value).toHaveProperty("message", (image as HackCliError).message); + const message = (value as HackCliError).message; + // The foreground-only hint stands on its own, not after the generic sentence. + expect(message).toStartWith( + `Native \`hack ${operation}\` runs in the foreground only, so --detach is not supported. Run \`hack ${operation}\` without --detach in its own terminal and keep it open;` + ); + expect(message).not.toContain("Native authored execution requires"); + expect(message).not.toContain("\n"); + } +); + if (process.platform !== "darwin") { test("unsupported host refuses native foreground startup before input or pins", async () => { const selected = await fixture(); diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 3fcfd9d0..cbb6b7ff 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -35,6 +35,7 @@ import { import { type invokeNativeRuntime, NativeRuntimeRequestError, + nativeCodesReportRuntimeNotRunning, } from "../src/backends/native-runtime-client.ts"; import { type ComposeEnvFingerprintRecord, @@ -208,6 +209,12 @@ type FixtureOptions = { readonly semanticAfterHook?: boolean; readonly planFailure?: boolean; readonly failBeforeReceipt?: boolean; + /** + * Like `failBeforeReceipt`, but serve fails the way authored foreground + * control reports a runtime that stopped: `runtime_not_running` wrapped in + * `native_graph_foreground`. + */ + readonly stoppedDuringServe?: boolean; /** Native startup code for failBeforeReceipt; defaults to graph_network_intent. */ readonly failBeforeReceiptCode?: string; /** Inspection after failBeforeReceipt proves the journal was never reserved. */ @@ -219,6 +226,20 @@ type FixtureOptions = { readonly changeDuringStatus?: boolean; readonly changeDuringPlan?: boolean; readonly noManaged?: boolean; + /** + * `runtime status --json` reports a stopped candidate runtime. Otherwise the + * status request fails like any unknown action, which the start treats as + * inconclusive. + */ + readonly runtimeStopped?: boolean; + /** `runtime status --json` hangs, so a caller can cancel during the check. */ + readonly runtimeStatusHangs?: boolean; + /** + * The `runtime status --json` request itself fails the way authored + * foreground control reports a stopped runtime: `runtime_not_running` + * wrapped in `native_graph_foreground`. + */ + readonly runtimeStatusWrappedStop?: boolean; readonly cleanup?: | "removed" | "live" @@ -415,6 +436,9 @@ console.log(JSON.stringify(result)); import {appendFile,readFile,readdir,writeFile} from 'node:fs/promises'; import {createHash} from 'node:crypto';import {join} from 'node:path'; const args=process.argv.slice(2);const action=args[4];await appendFile(${JSON.stringify(calls)},JSON.stringify(args)+'\\n'); +if(${options.runtimeStopped === true}&&args[2]==='runtime'&&args[3]==='status'){console.log(JSON.stringify({phase:'stopped',process_alive:false}));process.exit(0)} +if(${options.runtimeStatusHangs === true}&&args[2]==='runtime'&&args[3]==='status'){await new Promise((resolve)=>setTimeout(resolve,10000));process.exit(0)} +if(${options.runtimeStatusWrappedStop === true}&&args[2]==='runtime'&&args[3]==='status'){console.error(JSON.stringify({code:'native_graph_foreground',cause_code:'runtime_not_running',message:${JSON.stringify(CANARY)}}));process.exit(2)} const journal=${JSON.stringify(journal)}; const root=${JSON.stringify(join(projectDir, ".internal", "native-authored-runs"))}; if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ @@ -459,6 +483,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ await writeFile(${JSON.stringify(delivery)},JSON.stringify({version:input.version,kind:input.kind,review:input.review,run:input.run,lifetime_seconds:input.lifetime_seconds,keys,digests,expectedDigest:${JSON.stringify(digest)}})); } if(${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:${JSON.stringify(options.failBeforeReceiptCode ?? "graph_network_intent")},message:${JSON.stringify(CANARY)}}));process.exit(2)} + if(${options.stoppedDuringServe === true}){console.error(JSON.stringify({code:'native_graph_foreground',cause_code:'runtime_not_running',message:${JSON.stringify(CANARY)}}));process.exit(2)} const receipt={version:2,kind:'native-graph-runtime',owner:'f'.repeat(32),boot:'00000000-0000-0000-0000-000000000001',review,phase:'ready-observed',readiness:{web:'started'},resources:{'network:default':{kind:'network',key:'default',name:'hkn-'+input.run+'-network-0',id:'1'.repeat(64),image:null,phase:'created',outbound:true},'container:web':{kind:'container',key:'web',name:'hkn-'+input.run+'-container-0',id:'2'.repeat(64),image:'sha256:'+ '3'.repeat(64),phase:'started',networks:['default']}}}; if(${options.storage === true && options.liveStop === true}){ const namespace=provenance.namespace, owner='9'.repeat(32); @@ -483,7 +508,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ } await finish(); }else{ - if(action==='inspect'&&${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:${JSON.stringify(options.unreservedInspection === true ? "native_graph_unreserved" : "native_graph_receipt")},message:${JSON.stringify(CANARY)}}));process.exit(2)} + if(action==='inspect'&&${options.failBeforeReceipt === true || options.stoppedDuringServe === true}){console.error(JSON.stringify({code:${JSON.stringify(options.unreservedInspection === true ? "native_graph_unreserved" : "native_graph_receipt")},message:${JSON.stringify(CANARY)}}));process.exit(2)} if(action==='inspect'&&${JSON.stringify(options.cleanup)}==='missing'){console.error(JSON.stringify({code:'graph_owner_recovery',message:'private-missing-journal-detail'}));process.exit(2)} const receipt=JSON.parse(await readFile(journal,'utf8')); const observations={web:receipt.phase==='removed'?null:{state:'running',health:'none'}}; @@ -588,7 +613,9 @@ macTest( .trim() .split("\n") .map((line) => JSON.parse(line)[4]); - expect(calls).toEqual(["plan", "serve", "inspect"]); + // `--json` is the read-only `runtime status --json` check that runs after + // review and before the startup intent is reserved. + expect(calls).toEqual(["plan", "--json", "serve", "inspect"]); }, 90_000 ); @@ -685,6 +712,24 @@ macTest( 90_000 ); +macTest( + "a runtime that stops during serve keeps the wrapped stopped-runtime cause", + async () => { + const selected = await fixture({ stoppedDuringServe: true }); + const error = await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + }), + "retained" + ); + expect(error).toHaveProperty("nativeCode", "native_graph_foreground"); + expect(error).toHaveProperty("nativeCauseCode", "runtime_not_running"); + expect(error).toHaveProperty("stage", "runtime"); + } +); + test("startup diagnostics accept only closed compiler codes", () => { const known = new NativeAuthoredProjectStartError({ outcome: "not-started", @@ -726,6 +771,114 @@ macTest( } ); +macTest( + "a stopped runtime refuses before the startup intent, so ps stays not_started", + async () => { + const selected = await fixture({ runtimeStopped: true }); + const error = await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + }), + "not-started" + ); + expect(error).toHaveProperty("nativeCode", "runtime_not_running"); + expect(error).toHaveProperty("stage", "reservation"); + // Planning ran; the read-only status check refused before reservation, so + // no graph was served and no environment was delivered. + const calls = (await Bun.file(selected.calls).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(calls.map((args) => args.slice(2, 5).join(" "))).toEqual([ + "graph native plan", + "runtime status --json", + ]); + expect(await Bun.file(selected.delivery).exists()).toBe(false); + expect(await artifacts(selected.scope)).toEqual([]); + expect( + await nativeAuthoredProjectPs({ + runtime: selected.runtime, + scope: selected.scope, + }) + ).toMatchObject({ status: "not_started", run: null, items: [] }); + } +); + +macTest( + "a wrapped stopped-runtime refusal from a runtime request keeps its cause code", + async () => { + const selected = await fixture({ runtimeStatusWrappedStop: true }); + const error = await failure( + serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + }), + "not-started" + ); + // failedAdmission takes both codes from the request error, so the CLI can + // still classify the wrapped form as a stopped runtime. + expect(error).toHaveProperty("nativeCode", "native_graph_foreground"); + expect(error).toHaveProperty("nativeCauseCode", "runtime_not_running"); + expect(error).toHaveProperty("stage", "reservation"); + expect( + nativeCodesReportRuntimeNotRunning( + error as { nativeCode?: string; nativeCauseCode?: string } + ) + ).toBe(true); + expect(await artifacts(selected.scope)).toEqual([]); + } +); + +macTest( + "cancel during the runtime status check refuses before the startup intent", + async () => { + const selected = await fixture({ runtimeStatusHangs: true }); + const signal = new AbortController(); + const started = serveNativeAuthoredProject({ + ...selected, + run, + signal: signal.signal, + startupTimeoutMs: 10_000, + }); + // Cancel once the status request is in flight. The check treats the + // aborted request as inconclusive; the start must still not reserve. + const deadline = performance.now() + 8000; + while ( + !( + (await Bun.file(selected.calls).exists()) && + (await Bun.file(selected.calls).text()).includes('"status"') + ) + ) { + if (performance.now() > deadline) { + throw new Error("runtime status was never requested"); + } + await Bun.sleep(20); + } + signal.abort(); + const error = await failure(started, "not-started"); + expect(error).toHaveProperty("canceled", true); + expect(error).toHaveProperty("stage", "reservation"); + const calls = (await Bun.file(selected.calls).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as string[]); + expect(calls.map((args) => args.slice(2, 5).join(" "))).toEqual([ + "graph native plan", + "runtime status --json", + ]); + expect(await artifacts(selected.scope)).toEqual([]); + expect( + await nativeAuthoredProjectPs({ + runtime: selected.runtime, + scope: selected.scope, + }) + ).toMatchObject({ status: "not_started", run: null, items: [] }); + } +); + macTest( "native frontend holds admission through exact ready publication and Removed retirement", async () => { diff --git a/tests/native-aws-environment.test.ts b/tests/native-aws-environment.test.ts index a19ffc40..dc2a0425 100644 --- a/tests/native-aws-environment.test.ts +++ b/tests/native-aws-environment.test.ts @@ -159,7 +159,10 @@ test("ambiguous/custom/writable AWS mounts refuse before exporting", async () => exportSource, now, }) - ).rejects.toThrow("values omitted"); + ).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: expect.stringContaining("values omitted"), + }); } expect(calls).toBe(0); }); diff --git a/tests/native-exec-environment.test.ts b/tests/native-exec-environment.test.ts index aa709ad8..a51cf118 100644 --- a/tests/native-exec-environment.test.ts +++ b/tests/native-exec-environment.test.ts @@ -82,7 +82,10 @@ test("fresh exec refuses missing selectors or deleted overlays before AWS export const { aws: _aws, ...legacy } = opts.run; await expect( prepareNativeExecEnvironment({ ...opts, run: legacy, adaptAws }) - ).rejects.toThrow("recorded startup"); + ).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: expect.stringContaining("recorded startup"), + }); await rm(join(opts.scope.projectDir, "hack.env.qa.yaml")); await expect( prepareNativeExecEnvironment({ @@ -90,7 +93,10 @@ test("fresh exec refuses missing selectors or deleted overlays before AWS export run: { ...opts.run, aws: { profile: "qa" } }, adaptAws, }) - ).rejects.toThrow("unavailable"); + ).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: expect.stringContaining("unavailable"), + }); expect(calls).toBe(0); }); test("fresh exec uses only saved AWS selection and omits adapter failure details", async () => { diff --git a/tests/native-https-port.test.ts b/tests/native-https-port.test.ts index a9b2bab2..4f45fdd5 100644 --- a/tests/native-https-port.test.ts +++ b/tests/native-https-port.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { createServer } from "node:net"; import { checkNativeHttpsPort } from "../src/backends/native-https-port.ts"; +import { expectCodedRefusal } from "./helpers/closed-refusal.ts"; test("port probe reports an existing listener without closing or adopting it", async () => { const server = createServer(); @@ -10,9 +11,13 @@ test("port probe reports an existing listener without closing or adopting it", a if (!address || typeof address === "string") { throw new Error("Fixture has no TCP address"); } - await expect(checkNativeHttpsPort(address.port)).rejects.toThrow( + const refusal = await expectCodedRefusal( + checkNativeHttpsPort(address.port), + "E_CONFIG_INVALID", "already in use" ); + // Value-free: the selected port number is not echoed. + expect(refusal.message).not.toContain(String(address.port)); expect(server.listening).toBe(true); } finally { await new Promise((resolve) => server.close(() => resolve())); diff --git a/tests/native-prepared-base.test.ts b/tests/native-prepared-base.test.ts index a7d322f8..111de565 100644 --- a/tests/native-prepared-base.test.ts +++ b/tests/native-prepared-base.test.ts @@ -3,6 +3,7 @@ import { parseNativePreparedBase, preparedBaseArguments, } from "../src/backends/native-prepared-base.ts"; +import { expectCodedThrow } from "./helpers/closed-refusal.ts"; test("prepared base is off unless a mode is selected", () => { expect(parseNativePreparedBase({})).toBeUndefined(); @@ -44,6 +45,10 @@ test("invalid selections are refused before any runtime effect", () => { HACK_NATIVE_PREPARED_BASE_STORE: "relative", }, ]) { - expect(() => parseNativePreparedBase(env)).toThrow(); + expectCodedThrow( + () => parseNativePreparedBase(env), + "E_CONFIG_INVALID", + "HACK_NATIVE_PREPARED_BASE" + ); } }); diff --git a/tests/native-project-adaptation.test.ts b/tests/native-project-adaptation.test.ts index f864b0cd..c8edb939 100644 --- a/tests/native-project-adaptation.test.ts +++ b/tests/native-project-adaptation.test.ts @@ -104,9 +104,16 @@ test("unsupported changes and conflicts refuse without mutating input", () => { { version: 1, additionalHostnames: { web: ["web.hack.gy"] } }, { version: 1, additionalHostnames: { web: ["*.hack.local"] } }, ]) { - expect(() => adaptNativeProject({ input: original, selection })).toThrow( - "values omitted" - ); + let refusal: unknown; + try { + adaptNativeProject({ input: original, selection }); + } catch (error: unknown) { + refusal = error; + } + expect(refusal).toMatchObject({ + code: "E_CONFIG_INVALID", + message: expect.stringContaining("values omitted"), + }); expect(original.normalizedComposeJson).toBe(bytes); } const compose = JSON.parse(bytes); diff --git a/tests/native-project-branch.test.ts b/tests/native-project-branch.test.ts index c0fa31df..9a75a778 100644 --- a/tests/native-project-branch.test.ts +++ b/tests/native-project-branch.test.ts @@ -176,9 +176,10 @@ test("invalid branch selectors and missing or malformed config refuse", async () expect(() => nativeProjectBranchArgs(branch)).toThrow("canonical branch"); } await writeFile(f.configFile, "{"); - await expect(prepareNativeProjectBranch(f)).rejects.toThrow( - "valid project host" - ); + await expect(prepareNativeProjectBranch(f)).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: expect.stringContaining("valid project host"), + }); await rm(f.configFile); await expect(prepareNativeProjectBranch(f)).rejects.toThrow( "valid project host" diff --git a/tests/native-project-cache.test.ts b/tests/native-project-cache.test.ts index a547abb0..23f3d256 100644 --- a/tests/native-project-cache.test.ts +++ b/tests/native-project-cache.test.ts @@ -1,10 +1,16 @@ import { expect, test } from "bun:test"; import { hasOnlyNativeCacheLabels, + nativeCacheInitializers, nativeSharedSourceFlags, + nativeStartCacheSource, publishNativeCacheSource, } from "../src/backends/native-project-cache.ts"; import type { NativeProjectReview } from "../src/backends/native-project-review.ts"; +import { + expectClosedRefusal, + expectClosedThrow, +} from "./helpers/closed-refusal.ts"; const planId = "a".repeat(64), namespace = "b".repeat(64), @@ -126,12 +132,40 @@ test("missing, foreign or unverified source publications cannot be admitted", as state: "guest-content-verified-no-job-started", }, ]) { - await expect( + await expectClosedRefusal( publishNativeCacheSource({ ...base, review: selected, invoke: async () => publication, - }) - ).rejects.toThrow("verified revision"); + }), + "verified revision" + ); } }); + +test("invalid reviewed cache and source replies refuse with closed messages", async () => { + expectClosedThrow( + () => nativeSharedSourceFlags(null), + "Native source review is invalid." + ); + expectClosedThrow( + () => nativeCacheInitializers({}), + "Native cache review is invalid." + ); + expectClosedThrow( + () => nativeCacheInitializers({ services: { web: null } }), + "Native service review is invalid." + ); + await expectClosedRefusal( + nativeStartCacheSource({ + runtime: { binary: "/fixture/native", home: "/fixture/home" }, + projectRoot: "/fixture/project", + review: {} as NativeProjectReview, + restoreRevision: "not-a-revision", + invoke: async () => { + throw new Error("must not invoke"); + }, + }), + "Native retained cache source revision is invalid." + ); +}); diff --git a/tests/native-project-cleanup-command.test.ts b/tests/native-project-cleanup-command.test.ts index f05526e5..b1c7d752 100644 --- a/tests/native-project-cleanup-command.test.ts +++ b/tests/native-project-cleanup-command.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { inspectNativeProjectCleanup } from "../src/backends/native-project-cleanup-command.ts"; import { saveNativeProjectRun } from "../src/backends/native-project-run.ts"; +import { ClosedCliError } from "../src/lib/cli-result.ts"; const roots: string[] = []; afterEach(async () => { @@ -146,6 +147,7 @@ test("foreign, incomplete and malformed graph observations refuse without raw va return value; }, }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(ClosedCliError); expect(String(failure)).toContain("inspection is unconfirmed"); expect(Bun.inspect(failure)).not.toContain("synthetic-private-canary"); expect(calls).toBe(1); diff --git a/tests/native-project-dependencies.test.ts b/tests/native-project-dependencies.test.ts index ce534168..3dc25989 100644 --- a/tests/native-project-dependencies.test.ts +++ b/tests/native-project-dependencies.test.ts @@ -9,6 +9,10 @@ import { readNativeHostDependencies, } from "../src/backends/native-project-dependencies.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { + expectCodedRefusal, + expectCodedThrow, +} from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -87,7 +91,7 @@ test("startup discovery stops on cancellation and never accepts a late query res await expect(waiting).rejects.toThrow(); expect(calls).toBe(1); const late = new AbortController(); - await expect( + await expectCodedRefusal( discoverNativeHostDependency({ ...discovery, wait: { deadlineMs: performance.now() + 2000, signal: late.signal }, @@ -95,8 +99,10 @@ test("startup discovery stops on cancellation and never accepts a late query res late.abort(); return { host_pid: 456, endpoint_fingerprint: "e".repeat(64) }; }, - }) - ).rejects.toThrow("values omitted"); + }), + "E_CONFIG_INVALID", + "values omitted" + ); }); test("startup discovery deadline bounds missing listeners and subsequent bindings", async () => { @@ -109,18 +115,22 @@ test("startup discovery deadline bounds missing listeners and subsequent binding calls++; throw unavailableListener(); }; - await expect( - discoverNativeHostDependency({ ...discovery, wait, invoke }) - ).rejects.toThrow("values omitted"); + await expectCodedRefusal( + discoverNativeHostDependency({ ...discovery, wait, invoke }), + "E_CONFIG_INVALID", + "values omitted" + ); const exhaustedCalls = calls; - await expect( + await expectCodedRefusal( discoverNativeHostDependency({ ...discovery, hostPort: 8444, wait, invoke, - }) - ).rejects.toThrow("values omitted"); + }), + "E_CONFIG_INVALID", + "values omitted" + ); expect(calls).toBe(exhaustedCalls); }); @@ -149,7 +159,7 @@ test("startup discovery does not retry unrelated failures or malformed results", expect(calls).toBe(1); } let malformedCalls = 0; - await expect( + await expectCodedRefusal( discoverNativeHostDependency({ ...discovery, wait: { @@ -160,8 +170,10 @@ test("startup discovery does not retry unrelated failures or malformed results", malformedCalls++; return { password: "synthetic-do-not-print" }; }, - }) - ).rejects.toThrow("values omitted"); + }), + "E_CONFIG_INVALID", + "values omitted" + ); expect(malformedCalls).toBe(1); }); @@ -207,9 +219,11 @@ test("dependency launchers add reaping init only when omitted and preserve expli expect(services.web?.init).toBe(true); expect(services.worker?.init).toBe(false); const refused = { web: { init: false } }; - expect(() => - prepareNativeDependencyServices({ dependencies, services: refused }) - ).toThrow("init: true"); + expectCodedThrow( + () => prepareNativeDependencyServices({ dependencies, services: refused }), + "E_NATIVE_PROJECT_UNSUPPORTED", + "init: true" + ); expect(refused.web.init).toBe(false); }); test("equal endpoints share transport across services while same-service bindings remain distinct", () => { @@ -243,9 +257,11 @@ test("wrapped supervisors require an explicit bounded executable selector", () = Number.NaN, Number.POSITIVE_INFINITY, ]) { - expect(() => - parse([{ ...selected, host_supervisor_depth: depth }]) - ).toThrow("values omitted"); + expectCodedThrow( + () => parse([{ ...selected, host_supervisor_depth: depth }]), + "E_CONFIG_INVALID", + "values omitted" + ); } expect(() => parse([{ ...binding(), host_supervisor_depth: 2 }])).toThrow( "values omitted" @@ -265,7 +281,11 @@ test("shared listener transports reject differing supervisor authority", () => { { ...second, host_executable: "/synthetic/foreign" }, binding("worker"), ]) { - expect(() => parse([first, replacement])).toThrow("values omitted"); + expectCodedThrow( + () => parse([first, replacement]), + "E_CONFIG_INVALID", + "values omitted" + ); } }); test("ambiguous, undeclared, wildcard or credential-bearing dependencies refuse without values", () => { @@ -288,7 +308,11 @@ test("ambiguous, undeclared, wildcard or credential-bearing dependencies refuse Array.from({ length: 33 }, (_, i) => binding("web", `h${i}`)), ]; for (const entries of bad) { - expect(() => parse(entries)).toThrow("values omitted"); + expectCodedThrow( + () => parse(entries), + "E_CONFIG_INVALID", + "values omitted" + ); } for (const value of [ null, @@ -299,9 +323,11 @@ test("ambiguous, undeclared, wildcard or credential-bearing dependencies refuse password: "synthetic-do-not-print", }, ]) { - expect(() => - parseNativeHostDependencies({ value, services: ["web"] }) - ).toThrow("values omitted"); + expectCodedThrow( + () => parseNativeHostDependencies({ value, services: ["web"] }), + "E_CONFIG_INVALID", + "values omitted" + ); } }); test("selection files are bounded regular files; symlinks and malformed private contents refuse", async () => { @@ -318,15 +344,19 @@ test("selection files are bounded regular files; symlinks and malformed private const link = join(root, "link.json"); await symlink(path, link); for (const candidate of [link, root, "relative.json"]) { - await expect( - readNativeHostDependencies({ path: candidate, services: ["web"] }) - ).rejects.toThrow("values omitted"); + await expectCodedRefusal( + readNativeHostDependencies({ path: candidate, services: ["web"] }), + "E_CONFIG_INVALID", + "values omitted" + ); } for (const body of ["synthetic-do-not-print", " ".repeat(65_537)]) { await writeFile(path, body); - await expect( - readNativeHostDependencies({ path, services: ["web"] }) - ).rejects.toThrow("values omitted"); + await expectCodedRefusal( + readNativeHostDependencies({ path, services: ["web"] }), + "E_CONFIG_INVALID", + "values omitted" + ); } expect(await readNativeHostDependencies({ services: ["web"] })).toEqual([]); }); @@ -365,10 +395,12 @@ test("executable-pinned selection refreshes one current PID per shared listener ]); expect(result[0]?.host_executable).toBe(executable); expect(result.map((entry) => entry.host_supervisor_depth)).toEqual([2, 2]); - await expect( - readNativeHostDependencies({ path, services: ["web", "worker"] }) - ).rejects.toThrow("values omitted"); - await expect( + await expectCodedRefusal( + readNativeHostDependencies({ path, services: ["web", "worker"] }), + "E_CONFIG_INVALID", + "values omitted" + ); + await expectCodedRefusal( readNativeHostDependencies({ path, services: ["web", "worker"], @@ -376,8 +408,10 @@ test("executable-pinned selection refreshes one current PID per shared listener host_pid: 456, endpoint_fingerprint: "invalid", }), - }) - ).rejects.toThrow("values omitted"); + }), + "E_CONFIG_INVALID", + "values omitted" + ); await writeFile( path, JSON.stringify({ @@ -386,9 +420,11 @@ test("executable-pinned selection refreshes one current PID per shared listener }) ); calls.length = 0; - await expect( - readNativeHostDependencies({ path, services: ["web"], discover }) - ).rejects.toThrow("values omitted"); + await expectCodedRefusal( + readNativeHostDependencies({ path, services: ["web"], discover }), + "E_CONFIG_INVALID", + "values omitted" + ); expect(calls).toEqual([]); await writeFile( path, @@ -397,9 +433,11 @@ test("executable-pinned selection refreshes one current PID per shared listener dependencies: [{ ...selected[0], host_supervisor_depth: 9 }], }) ); - await expect( - readNativeHostDependencies({ path, services: ["web"], discover }) - ).rejects.toThrow("values omitted"); + await expectCodedRefusal( + readNativeHostDependencies({ path, services: ["web"], discover }), + "E_CONFIG_INVALID", + "values omitted" + ); expect(calls).toEqual([]); }); @@ -438,7 +476,7 @@ test("logical grant bounds and endpoint identity remain independent of transport services, }); expect(select(entries.slice(0, 128))).toHaveLength(128); - expect(() => select(entries)).toThrow("values omitted"); + expectCodedThrow(() => select(entries), "E_CONFIG_INVALID", "values omitted"); expect( select([ { ...binding("service-0"), host_pid: 123 }, diff --git a/tests/native-project-dependency-refresh.test.ts b/tests/native-project-dependency-refresh.test.ts index 70cdc0ed..c235d872 100644 --- a/tests/native-project-dependency-refresh.test.ts +++ b/tests/native-project-dependency-refresh.test.ts @@ -9,6 +9,7 @@ import { saveNativeProjectRun, } from "../src/backends/native-project-run.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -89,15 +90,16 @@ test("refresh validates the owned no-op or bounded changed slots with one public test("missing owned mapping never invokes refresh", async () => { const opts = await fixture(false); let calls = 0; - await expect( + await expectClosedRefusal( refreshNativeProjectDependencies({ ...opts, invoke: async () => { calls++; return response(); }, - }) - ).rejects.toThrow("not been started"); + }), + "not been started" + ); expect(calls).toBe(0); }); @@ -260,7 +262,7 @@ test("mapping removal, replacement or selector drift during refresh cannot autho for (const change of ["removed", "run", "selector"]) { const opts = await fixture(); let calls = 0; - await expect( + await expectClosedRefusal( refreshNativeProjectDependencies({ ...opts, invoke: async () => { @@ -277,8 +279,9 @@ test("mapping removal, replacement or selector drift during refresh cannot autho } return response(); }, - }) - ).rejects.toThrow("identity or outcome is unconfirmed"); + }), + "identity or outcome is unconfirmed" + ); expect(calls).toBe(1); } }); diff --git a/tests/native-project-down.test.ts b/tests/native-project-down.test.ts index 2ef474f2..df41b036 100644 --- a/tests/native-project-down.test.ts +++ b/tests/native-project-down.test.ts @@ -17,6 +17,7 @@ import { removeNativeProjectRun, saveNativeProjectRun, } from "../src/backends/native-project-run.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const roots: string[] = []; @@ -166,7 +167,7 @@ test("missing frontend identity refuses before hooks or cleanup without leaking return snapshot(); }, }); - await expect(result).rejects.toThrow("frontend finalization is unconfirmed"); + await expectClosedRefusal(result, "frontend finalization is unconfirmed"); try { await result; } catch (error) { @@ -179,7 +180,7 @@ test("missing frontend identity refuses before hooks or cleanup without leaking test("frontend identity changed by a before hook refuses before compute cleanup", async () => { const opts = await fixture(); const calls: string[] = []; - await expect( + await expectClosedRefusal( nativeProjectDown({ ...opts, before: async () => { @@ -190,8 +191,9 @@ test("frontend identity changed by a before hook refuses before compute cleanup" calls.push(request.args[1]!); return snapshot(); }, - }) - ).rejects.toThrow("ownership changed"); + }), + "ownership changed" + ); expect(calls).toEqual(["inspect", "before", "inspect"]); expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); @@ -206,7 +208,7 @@ test("stopped compute cannot borrow a wrong or missing frontend completion", asy const root = finalizationRoot(opts.scope); const calls: string[] = []; let cleaned = false; - await expect( + await expectClosedRefusal( nativeProjectDown({ ...opts, finalizationTimeoutMs: 1, @@ -232,8 +234,9 @@ test("stopped compute cannot borrow a wrong or missing frontend completion", asy } return snapshot(cleaned); }, - }) - ).rejects.toThrow("frontend finalization is unconfirmed"); + }), + "frontend finalization is unconfirmed" + ); expect(calls).toEqual(["inspect", "inspect", "cleanup", "inspect"]); expect(await loadNativeProjectRun(opts.scope)).toEqual(run); } @@ -290,7 +293,7 @@ test("already-stopped unacknowledged frontend refuses without replaying compute" ); await rm(join(root, "completed.json")); const calls: string[] = []; - await expect( + await expectClosedRefusal( nativeProjectDown({ ...opts, finalizationTimeoutMs: 1, @@ -298,8 +301,9 @@ test("already-stopped unacknowledged frontend refuses without replaying compute" calls.push(request.args[1]!); return snapshot(true); }, - }) - ).rejects.toThrow("frontend finalization is unconfirmed"); + }), + "frontend finalization is unconfirmed" + ); expect(calls).toEqual(["inspect"]); expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); @@ -377,7 +381,7 @@ test("down invokes cleanup once and retains the stopped mapping for later up", a test("unconfirmed cleanup retains mapping and skips after hook", async () => { const opts = await fixture(); let effects = 0; - await expect( + await expectClosedRefusal( nativeProjectDown({ ...opts, retireHostProcesses: async () => { @@ -393,8 +397,9 @@ test("unconfirmed cleanup retains mapping and skips after hook", async () => { } return snapshot(); }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(effects).toBe(1); expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); @@ -422,7 +427,7 @@ test("foreground concurrent mapping removal is accepted only after confirmed cle test("foreign owner refuses before hooks or cleanup", async () => { const opts = await fixture(); let calls = 0; - await expect( + await expectClosedRefusal( nativeProjectDown({ ...opts, before: async () => { @@ -434,8 +439,9 @@ test("foreign owner refuses before hooks or cleanup", async () => { value.receipt.owner = "f".repeat(32); return value; }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(calls).toBe(1); expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); @@ -562,16 +568,18 @@ test("down environment preserves explicit base and named overlay while legacy re }) ).toEqual({ VALUE: "fresh-value" }); await rm(join(opts.scope.projectDir, "hack.env.qa.yaml")); - await expect( + await expectClosedRefusal( nativeDownEnvironment({ scope: opts.scope, run: { ...run, effectiveEnvName: "qa" }, serviceNames: ["web"], - }) - ).rejects.toThrow("unavailable"); - await expect( - nativeDownEnvironment({ scope: opts.scope, run, serviceNames: ["web"] }) - ).rejects.toThrow("legacy"); + }), + "unavailable" + ); + await expectClosedRefusal( + nativeDownEnvironment({ scope: opts.scope, run, serviceNames: ["web"] }), + "legacy" + ); }); test("before failure prevents cleanup; after failure retains the stopped mapping", async () => { for (const phase of ["before", "after"] as const) { @@ -786,7 +794,7 @@ test("down recovers a selected interrupted startup without replaying hooks or or test("down preserves a partial ready-graph shutdown without hooks, stop replay or startup recovery", async () => { const opts = await fixture(); const calls: string[] = []; - await expect( + await expectClosedRefusal( nativeProjectDown({ ...opts, before: async () => { @@ -809,15 +817,16 @@ test("down preserves a partial ready-graph shutdown without hooks, stop replay o pending_cleanup: { version: 1, kind: "partial_shutdown" }, }; }, - }) - ).rejects.toThrow("Native retaining shutdown is incomplete"); + }), + "Native retaining shutdown is incomplete" + ); expect(calls).toEqual(["inspect"]); expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); test("stopped receipt with a remaining container preserves mapping", async () => { const opts = await fixture(); - await expect( + await expectClosedRefusal( nativeProjectDown({ ...opts, retireHostProcesses: async () => { @@ -828,8 +837,9 @@ test("stopped receipt with a remaining container preserves mapping", async () => value.receipt.phase = "stopped-data-retained"; return value; }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(await loadNativeProjectRun(opts.scope)).toEqual(run); }); diff --git a/tests/native-project-exec.test.ts b/tests/native-project-exec.test.ts index 17808bda..798b006f 100644 --- a/tests/native-project-exec.test.ts +++ b/tests/native-project-exec.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { nativeProjectExec } from "../src/backends/native-project-exec.ts"; import { saveNativeProjectRun } from "../src/backends/native-project-run.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; const roots: string[] = []; @@ -103,19 +104,21 @@ test("invalid arguments and absent mapping never execute", async () => { const invoke = async () => { throw new Error("unexpected runtime"); }; - await expect( - nativeProjectExec({ ...opts, service: "web", argv: ["true"], invoke }) - ).rejects.toThrow("not been started"); + await expectClosedRefusal( + nativeProjectExec({ ...opts, service: "web", argv: ["true"], invoke }), + "not been started" + ); for (const argv of [[], [""], ["echo", "\0"]]) { - await expect( - nativeProjectExec({ ...opts, service: "web", argv, invoke }) - ).rejects.toThrow("requires"); + await expectClosedRefusal( + nativeProjectExec({ ...opts, service: "web", argv, invoke }), + "requires" + ); } }); test("dead foreground owner refuses one-off exec before command effects", async () => { const opts = await fixture(); let executions = 0; - await expect( + await expectClosedRefusal( nativeProjectExec({ ...opts, service: "web", @@ -137,8 +140,9 @@ test("dead foreground owner refuses one-off exec before command effects", async executions++; throw new Error("unexpected effect"); }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(executions).toBe(0); }); test("foreign receipt refuses before exec and changed container after exec is uncertain without replay", async () => { @@ -161,11 +165,12 @@ test("foreign receipt refuses before exec and changed container after exec is un value.receipt.owner = "f".repeat(32); return value; }; - await expect( - nativeProjectExec({ ...opts, service: "web", argv: ["true"], invoke }) - ).rejects.toThrow("mapping"); + await expectClosedRefusal( + nativeProjectExec({ ...opts, service: "web", argv: ["true"], invoke }), + "mapping" + ); expect(executions).toBe(0); - await expect( + await expectClosedRefusal( nativeProjectExec({ ...opts, service: "web", @@ -189,8 +194,9 @@ test("foreign receipt refuses before exec and changed container after exec is un } return value; }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(executions).toBe(1); }); @@ -231,7 +237,7 @@ test("malformed exec output refuses without replay", async () => { const opts = await fixture(); for (const bad of ["not base64!", "Zh=="]) { let executions = 0; - await expect( + await expectClosedRefusal( nativeProjectExec({ ...opts, service: "web", @@ -251,8 +257,9 @@ test("malformed exec output refuses without replay", async () => { truncated: false, }; }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(executions).toBe(1); } }); @@ -393,7 +400,7 @@ test("fresh exec clears private input on transport failure and never retries", a test("changed exec selection refuses before private environment delivery", async () => { const opts = await freshFixture({ FIXTURE: "private-fixture-value" }); let executions = 0; - await expect( + await expectClosedRefusal( nativeProjectExec({ ...opts, service: "web", @@ -411,8 +418,9 @@ test("changed exec selection refuses before private environment delivery", async executions++; throw new Error("unexpected execution"); }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(executions).toBe(0); }); diff --git a/tests/native-project-finalization.test.ts b/tests/native-project-finalization.test.ts index 23b98396..3992fe54 100644 --- a/tests/native-project-finalization.test.ts +++ b/tests/native-project-finalization.test.ts @@ -17,6 +17,7 @@ import { recoverNativeProjectFinalization, waitNativeProjectFinalization, } from "../src/backends/native-project-finalization.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -68,21 +69,23 @@ test("shared HTTPS finalization v3 binds the exact lease and never inherits a st { ...lease, namespace: "f".repeat(64) }, { ...lease, planId: "f".repeat(64) }, ]) { - await expect( + await expectClosedRefusal( beginNativeProjectFinalization({ ...opts, httpsPort: 18_443, httpsLease: invalid, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); } - await expect( + await expectClosedRefusal( beginNativeProjectFinalization({ ...opts, httpsPort: null, httpsLease: lease, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(await readdir(opts.scope.nativeHome)).toEqual([".hack"]); const first = await beginNativeProjectFinalization({ ...opts, @@ -110,14 +113,15 @@ test("shared HTTPS finalization v3 binds the exact lease and never inherits a st httpsLease: secondLease, }); expect(second.token.attempt).toBe(first.token.attempt); - await expect( + await expectClosedRefusal( waitNativeProjectFinalization({ ...opts, token: second.token, timeoutMs: 1, - }) - ).rejects.toThrow("unconfirmed"); - await expect(first.complete()).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); + await expectClosedRefusal(first.complete(), "unconfirmed"); if (second.token.version !== 3) { throw new Error("expected v3 finalizer"); } @@ -126,9 +130,10 @@ test("shared HTTPS finalization v3 binds the exact lease and never inherits a st httpsLease: { ...second.token.httpsLease, leaseId: "6".repeat(32) }, }; expect(isNativeProjectFinalizationToken(forged)).toBe(true); - await expect( - waitNativeProjectFinalization({ ...opts, token: forged, timeoutMs: 1 }) - ).rejects.toThrow("unconfirmed"); + await expectClosedRefusal( + waitNativeProjectFinalization({ ...opts, token: forged, timeoutMs: 1 }), + "unconfirmed" + ); await second.complete(); await waitNativeProjectFinalization({ ...opts, @@ -156,7 +161,7 @@ test("dead v3 frontend recovery requires its exact persisted HTTPS lease", async observations++; }, }; - await expect( + await expectClosedRefusal( recoverNativeProjectFinalization({ ...recovery, token: { @@ -166,8 +171,9 @@ test("dead v3 frontend recovery requires its exact persisted HTTPS lease", async ownerGeneration: "f".repeat(32), }, }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(observations).toBe(0); await expect( recoverNativeProjectFinalization({ @@ -177,13 +183,14 @@ test("dead v3 frontend recovery requires its exact persisted HTTPS lease", async }, }) ).rejects.toThrow("lease recovery uncertain"); - await expect( + await expectClosedRefusal( waitNativeProjectFinalization({ ...opts, token: lifetime.token, timeoutMs: 1, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); await recoverNativeProjectFinalization(recovery); expect(observations).toBe(1); await waitNativeProjectFinalization({ @@ -197,25 +204,28 @@ test("capture binds exact attempt and waits for explicit finalizer completion", const lifetime = await beginNativeProjectFinalization(opts); const token = await captureNativeProjectFinalization(opts); expect(token).toEqual(lifetime.token); - await expect( - waitNativeProjectFinalization({ ...opts, token, timeoutMs: 1 }) - ).rejects.toThrow("unconfirmed"); + await expectClosedRefusal( + waitNativeProjectFinalization({ ...opts, token, timeoutMs: 1 }), + "unconfirmed" + ); await lifetime.complete(); await waitNativeProjectFinalization({ ...opts, token, timeoutMs: 1 }); }); test("same-run restore cannot reuse stale acknowledgement or supersede incomplete owner", async () => { const opts = await fixture(); const old = await beginNativeProjectFinalization(opts); - await expect(beginNativeProjectFinalization(opts)).rejects.toThrow( + await expectClosedRefusal( + beginNativeProjectFinalization(opts), "unconfirmed" ); await old.complete(); const next = await beginNativeProjectFinalization(opts); expect(next.token.attempt).not.toBe(old.token.attempt); - await expect( - waitNativeProjectFinalization({ ...opts, token: next.token, timeoutMs: 1 }) - ).rejects.toThrow("unconfirmed"); - await expect(old.complete()).rejects.toThrow("unconfirmed"); + await expectClosedRefusal( + waitNativeProjectFinalization({ ...opts, token: next.token, timeoutMs: 1 }), + "unconfirmed" + ); + await expectClosedRefusal(old.complete(), "unconfirmed"); await next.complete(); await waitNativeProjectFinalization({ ...opts, @@ -299,19 +309,21 @@ test("dead frontend recovery is explicit, exact, and distinct from completion", observations++; }, }; - await expect( + await expectClosedRefusal( recoverNativeProjectFinalization({ ...recovery, expectAttempt: "f".repeat(32), isDead: () => true, - }) - ).rejects.toThrow("unconfirmed"); - await expect( + }), + "unconfirmed" + ); + await expectClosedRefusal( recoverNativeProjectFinalization({ ...recovery, isDead: () => false, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(observations).toBe(0); await expect( recoverNativeProjectFinalization({ @@ -322,13 +334,14 @@ test("dead frontend recovery is explicit, exact, and distinct from completion", }, }) ).rejects.toThrow("live HTTPS owner"); - await expect( + await expectClosedRefusal( waitNativeProjectFinalization({ ...opts, token: lifetime.token, timeoutMs: 1, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); await recoverNativeProjectFinalization({ ...recovery, isDead: () => true, @@ -341,14 +354,15 @@ test("dead frontend recovery is explicit, exact, and distinct from completion", }); const next = await beginNativeProjectFinalization(opts); expect(next.token.attempt).not.toBe(lifetime.token.attempt); - await expect( + await expectClosedRefusal( waitNativeProjectFinalization({ ...opts, token: lifetime.token, timeoutMs: 1, - }) - ).rejects.toThrow("unconfirmed"); - await expect(lifetime.complete()).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); + await expectClosedRefusal(lifetime.complete(), "unconfirmed"); }); test("legacy recovery requires the observed PID and preserves stale completion", async () => { @@ -381,7 +395,8 @@ test("legacy recovery requires the observed PID and preserves stale completion", verifyEffects: async () => undefined, isDead: () => true, }; - await expect(recoverNativeProjectFinalization(recovery)).rejects.toThrow( + await expectClosedRefusal( + recoverNativeProjectFinalization(recovery), "unconfirmed" ); await recoverNativeProjectFinalization({ ...recovery, legacyPid: 52_141 }); diff --git a/tests/native-project-https.test.ts b/tests/native-project-https.test.ts index dd5a519d..58d05b5f 100644 --- a/tests/native-project-https.test.ts +++ b/tests/native-project-https.test.ts @@ -30,6 +30,7 @@ import { } from "../src/backends/native-project-https.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; import { CURRENT_CA_PEM, OLD_CA_PEM } from "./helpers/ca-certificates.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const cleanups: Array<() => Promise> = []; afterEach(async () => { @@ -274,12 +275,13 @@ test("owner receipt verifier refuses a replaced listener", async () => { const running = await startNativeProjectHttps(f.opts); cleanups.push(() => running.close()); f.wrongListener(); - await expect( + await expectClosedRefusal( inspectActiveNativeHttpsOwner({ runtime: f.opts.runtime, invoke: f.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); }); test("peer proof requires the accepted socket and exact receipted listener", async () => { const f = await fixture(); @@ -309,14 +311,15 @@ test("peer proof requires the accepted socket and exact receipted listener", asy "--json", ]); f.wrongPeer(); - await expect( + await expectClosedRefusal( verifyActiveNativeHttpsConnection({ runtime: f.opts.runtime, owner, peerPort: 52_342, invoke: f.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); const other = await fixture(); const otherRunning = await startNativeProjectHttps(other.opts); cleanups.push(() => otherRunning.close()); @@ -325,38 +328,41 @@ test("peer proof requires the accepted socket and exact receipted listener", asy invoke: other.opts.dependencies.invoke, }); other.rejectPeer(); - await expect( + await expectClosedRefusal( verifyActiveNativeHttpsConnection({ runtime: other.opts.runtime, owner: otherOwner, peerPort: 52_343, invoke: other.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); }); test("owner receipt verifier refuses CA rotation", async () => { const f = await fixture(); const running = await startNativeProjectHttps(f.opts); cleanups.push(() => running.close()); await writeFile(running.caPath, OLD_CA_PEM); - await expect( + await expectClosedRefusal( inspectActiveNativeHttpsOwner({ runtime: f.opts.runtime, invoke: f.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); }); test("owner receipt verifier refuses a dead frontend challenge even while children remain", async () => { const f = await fixture(); const running = await startNativeProjectHttps(f.opts); cleanups.push(() => running.close()); await rm(join(f.root, "native-https/owner.sock")); - await expect( + await expectClosedRefusal( inspectActiveNativeHttpsOwner({ runtime: f.opts.runtime, invoke: f.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); }); test("owner receipt verifier refuses malformed or publicly readable receipts", async () => { const f = await fixture(); @@ -365,20 +371,22 @@ test("owner receipt verifier refuses malformed or publicly readable receipts", a const receipt = join(f.root, "native-https/active-owner.json"); const original = await readFile(receipt); await writeFile(receipt, "{}"); - await expect( + await expectClosedRefusal( inspectActiveNativeHttpsOwner({ runtime: f.opts.runtime, invoke: f.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); await writeFile(receipt, original); await chmod(receipt, 0o644); - await expect( + await expectClosedRefusal( inspectActiveNativeHttpsOwner({ runtime: f.opts.runtime, invoke: f.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); await chmod(receipt, 0o600); }); test("owner challenge refuses a valid receipt with a different signing key", async () => { @@ -393,12 +401,13 @@ test("owner challenge refuses a valid receipt with a different signing key", asy .export({ format: "der", type: "spki" }) .toString("base64"); await writeFile(receipt, JSON.stringify(parsed)); - await expect( + await expectClosedRefusal( inspectActiveNativeHttpsOwner({ runtime: f.opts.runtime, invoke: f.opts.dependencies.invoke, - }) - ).rejects.toThrow("ownership verification failed"); + }), + "ownership verification failed" + ); await writeFile(receipt, original); }); test("owner socket preparation failure retires only its bound socket and lock", async () => { @@ -760,7 +769,7 @@ while True: peerPort ); await rm(seenPath); - await expect( + await expectClosedRefusal( verifyNativeHttpsHostname( "fixture.invalid", address.port, @@ -769,22 +778,25 @@ while True: async () => { throw new Error("untrusted peer detail"); } - ) - ).rejects.toThrow("Native HTTPS verification failed"); + ), + "Native HTTPS verification failed" + ); expect(await Bun.file(seenPath).exists()).toBe(false); - await expect( - verifyNativeHttpsHostname("wrong.invalid", address.port, cert, "/health") - ).rejects.toThrow("Native HTTPS verification failed"); + await expectClosedRefusal( + verifyNativeHttpsHostname("wrong.invalid", address.port, cert, "/health"), + "Native HTTPS verification failed" + ); const foreignCa = join(root, "foreign.pem"); await writeFile(foreignCa, CURRENT_CA_PEM); - await expect( + await expectClosedRefusal( verifyNativeHttpsHostname( "fixture.invalid", address.port, foreignCa, "/health" - ) - ).rejects.toThrow("Native HTTPS verification failed"); + ), + "Native HTTPS verification failed" + ); await writeFile( responsePath, "HTTP/1.1 307 Temporary Redirect\r\nLocation: https://canonical.invalid/health\r\nContent-Length: 0\r\n\r\n" @@ -804,14 +816,15 @@ while True: "HTTP/1.1 200 OK", ]) { await writeFile(responsePath, invalid); - await expect( + await expectClosedRefusal( verifyNativeHttpsHostname( "fixture.invalid", address.port, cert, "/health" - ) - ).rejects.toThrow("Native HTTPS verification failed"); + ), + "Native HTTPS verification failed" + ); } }); diff --git a/tests/native-project-input.test.ts b/tests/native-project-input.test.ts index 3714e8ce..ed7598f9 100644 --- a/tests/native-project-input.test.ts +++ b/tests/native-project-input.test.ts @@ -108,9 +108,10 @@ test("env_file and malformed modern input refuse without diagnostic contents", a await env(opts.projectDir, "default", { global: { TOKEN: { secure: "invalid-secret-canary" } }, }); - await expect(prepareNativeProjectInput(opts)).rejects.toThrow( - "values omitted" - ); + await expect(prepareNativeProjectInput(opts)).rejects.toMatchObject({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: expect.stringContaining("values omitted"), + }); await writeFile(opts.composeFile, '{"services":{"api":{"image":"bun:1"}}}'); await writeFile( join(opts.projectDir, "hack.env.default.yaml"), @@ -133,9 +134,10 @@ test("legacy dotenv refuses and unmanaged public projects preserve original fiel JSON.parse((await prepareNativeProjectInput(opts)).normalizedComposeJson) ).toEqual(original); await writeFile(join(opts.projectDir, ".env"), "PRIVATE=synthetic\n"); - await expect(prepareNativeProjectInput(opts)).rejects.toThrow( - "values omitted" - ); + await expect(prepareNativeProjectInput(opts)).rejects.toMatchObject({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: expect.stringContaining("values omitted"), + }); }); test("linked worktree inherits primary local environment and observes checkout overrides", async () => { diff --git a/tests/native-project-interrupted-cleanup.test.ts b/tests/native-project-interrupted-cleanup.test.ts index 41dced37..ea9ea5ce 100644 --- a/tests/native-project-interrupted-cleanup.test.ts +++ b/tests/native-project-interrupted-cleanup.test.ts @@ -4,6 +4,7 @@ import { recoverNativeInterruptedStartupCleanup, } from "../src/backends/native-project-interrupted-cleanup.ts"; import type { invokeNativeRuntime } from "../src/backends/native-runtime-client.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const run = { run: "a".repeat(32), @@ -156,7 +157,7 @@ test("presentation cannot bypass foreign selection or an unfinished startup jour const foreign = snapshot(); foreign.receipt.owner = "changed"; let calls = 0; - await expect( + await expectClosedRefusal( recoverNativeInterruptedStartupCleanup({ runtime, projectRoot: "/not-used", @@ -169,12 +170,13 @@ test("presentation cannot bypass foreign selection or an unfinished startup jour calls++; throw new Error("unexpected recovery"); }, - }) - ).rejects.toThrow("startup cleanup is unconfirmed"); + }), + "startup cleanup is unconfirmed" + ); expect(calls).toBe(0); const partial = snapshot(); partial.interrupted_start_cleanup_incomplete = true; - await expect( + await expectClosedRefusal( recoverNativeInterruptedStartupCleanup({ runtime, projectRoot: "/not-used", @@ -187,8 +189,9 @@ test("presentation cannot bypass foreign selection or an unfinished startup jour calls++; throw new Error("synthetic-private-canary"); }, - }) - ).rejects.toThrow("startup cleanup is unconfirmed"); + }), + "startup cleanup is unconfirmed" + ); expect(calls).toBe(1); }); @@ -223,7 +226,7 @@ test("a native journal hint cannot grant authority to a different graph phase", partial.interrupted_start_cleanup_incomplete = true; partial.receipt.phase = "ready-observed"; let calls = 0; - await expect( + await expectClosedRefusal( recoverNativeInterruptedStartupCleanup({ runtime, projectRoot: "/not-used", @@ -233,8 +236,9 @@ test("a native journal hint cannot grant authority to a different graph phase", calls++; throw new Error("unexpected recovery"); }, - }) - ).rejects.toThrow("cleanup is unconfirmed"); + }), + "cleanup is unconfirmed" + ); expect(calls).toBe(0); }); @@ -253,7 +257,7 @@ test("foreign or incomplete graph selection refuses before native requests", asy value.receipt[field] = "invalid"; } let calls = 0; - await expect( + await expectClosedRefusal( recoverNativeInterruptedStartupCleanup({ runtime, projectRoot: "/not-used", @@ -263,8 +267,9 @@ test("foreign or incomplete graph selection refuses before native requests", asy calls++; throw new Error("synthetic-private-canary"); }, - }) - ).rejects.toThrow("cleanup is unconfirmed"); + }), + "cleanup is unconfirmed" + ); expect(calls).toBe(0); } }); @@ -391,14 +396,15 @@ test("recovery acknowledgement does not replace fresh exact retained-data observ } return final; }; - await expect( + await expectClosedRefusal( recoverNativeInterruptedStartupCleanup({ runtime, projectRoot: "/not-used", run, snapshot: snapshot(), invoke, - }) - ).rejects.toThrow("cleanup is unconfirmed"); + }), + "cleanup is unconfirmed" + ); } }); diff --git a/tests/native-project-network.test.ts b/tests/native-project-network.test.ts index 2c6f2017..ec7cf696 100644 --- a/tests/native-project-network.test.ts +++ b/tests/native-project-network.test.ts @@ -3,6 +3,7 @@ import { parseNativeAllowedHosts, validateNativeAllowedHosts, } from "../src/backends/native-project-network.ts"; +import { expectCodedThrow } from "./helpers/closed-refusal.ts"; test("CSV selection is explicit, canonical, bounded and immutable", () => { expect(parseNativeAllowedHosts(undefined)).toEqual([]); @@ -40,7 +41,9 @@ test("CSV selection is explicit, canonical, bounded and immutable", () => { `${"x".repeat(64)}.com`, "x".repeat(8128), ]) { - expect(() => parseNativeAllowedHosts(value)).toThrow( + expectCodedThrow( + () => parseNativeAllowedHosts(value), + "E_CONFIG_INVALID", "HACK_NATIVE_ALLOW_HOSTS" ); } diff --git a/tests/native-project-observe.test.ts b/tests/native-project-observe.test.ts index ea852312..df71d9e9 100644 --- a/tests/native-project-observe.test.ts +++ b/tests/native-project-observe.test.ts @@ -416,13 +416,16 @@ test("unsupported native forms and missing restart consent refuse before Docker expect(code).not.toBe(0); expect(stdout + stderr).toContain( operation === "up" - ? "foreground whole-project startup only" + ? "E_USAGE: Native `hack up` runs in the foreground only, so --detach is not supported." : operation === "restart" - ? "Native restart requires HACK_NATIVE_SHARED_SOURCE=1" + ? "E_PERMISSION: Native restart requires HACK_NATIVE_SHARED_SOURCE=1" : operation === "exec" ? "Native project has not been started" : "Native run currently requires a started project" ); + // A known refusal is one closed line: no command help and no stack. + expect(stdout + stderr).not.toContain("hack [options]"); + expect(stdout + stderr).not.toMatch(/^\s+at /m); } finally { clearTimeout(timer); child.kill("SIGKILL"); diff --git a/tests/native-project-one-off.test.ts b/tests/native-project-one-off.test.ts index fa734657..f792f40e 100644 --- a/tests/native-project-one-off.test.ts +++ b/tests/native-project-one-off.test.ts @@ -7,6 +7,7 @@ import { removeNativeProjectRun, saveNativeProjectRun, } from "../src/backends/native-project-run.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -114,7 +115,7 @@ test("one-off preserves default command or literal argv and fresh private delive test("stale selection and missing project never request a job", async () => { const opts = await fixture(); let mutations = 0; - await expect( + await expectClosedRefusal( nativeProjectOneOff({ ...opts, argv: [], @@ -127,18 +128,40 @@ test("stale selection and missing project never request a job", async () => { } return { ...selection, plan: "0".repeat(64) }; }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(mutations).toBe(0); - await expect( + await expectClosedRefusal( nativeProjectOneOff({ ...(await fixture(false)), argv: [], invoke: async () => { throw new Error("must not invoke"); }, - }) - ).rejects.toThrow("started project"); + }), + "started project" + ); +}); + +test("invalid one-off arguments refuse with a closed message before any request", async () => { + const opts = await fixture(); + for (const request of [ + { argv: [""] }, + { argv: ["echo", "\0"] }, + { argv: ["true"], workdir: "relative" }, + ]) { + await expectClosedRefusal( + nativeProjectOneOff({ + ...opts, + ...request, + invoke: async () => { + throw new Error("must not invoke"); + }, + }), + "Native run requires a service, bounded arguments and an optional absolute workdir." + ); + } }); test("one-off explicitly delivers an empty environment and wipes it after completion", async () => { @@ -223,7 +246,7 @@ test("unconfirmed cleanup or transport failure never replays and wipes delivery" test("environment preparation cannot authorize a job after the project mapping changes", async () => { const opts = await fixture(); const calls: string[] = []; - await expect( + await expectClosedRefusal( nativeProjectOneOff({ ...opts, argv: [], @@ -242,8 +265,9 @@ test("environment preparation cannot authorize a job after the project mapping c } return selection; }, - }) - ).rejects.toThrow("unconfirmed"); + }), + "unconfirmed" + ); expect(calls).toEqual(["refresh-dependencies", "run-selection"]); }); diff --git a/tests/native-project-process.test.ts b/tests/native-project-process.test.ts index 919ea5b0..49e17032 100644 --- a/tests/native-project-process.test.ts +++ b/tests/native-project-process.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { serveNativeProjectGraph } from "../src/backends/native-project-process.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const roots: string[] = []; const run = "a".repeat(32); @@ -57,39 +58,42 @@ test("wrong readiness identity never publishes a mapping and stops the owned chi `console.log(JSON.stringify({kind:"graph_foreground_ready",run:"${"b".repeat(32)}"})); await Bun.sleep(10000);` ); let ready = false; - await expect( + await expectClosedRefusal( serveNativeProjectGraph({ ...opts, onReady: async () => { ready = true; }, - }) - ).rejects.toThrow("identity"); + }), + "identity" + ); expect(ready).toBe(false); }); test("startup timeout and pre-cancellation do not become readiness", async () => { const opts = await fixture("await Bun.sleep(10000)"); let ready = false; - await expect( + await expectClosedRefusal( serveNativeProjectGraph({ ...opts, startupTimeoutMs: 30, onReady: async () => { ready = true; }, - }) - ).rejects.toThrow("interrupted"); + }), + "interrupted" + ); const controller = new AbortController(); controller.abort(); - await expect( + await expectClosedRefusal( serveNativeProjectGraph({ ...opts, signal: controller.signal, onReady: async () => { ready = true; }, - }) - ).rejects.toThrow("canceled"); + }), + "canceled" + ); expect(ready).toBe(false); }); test("failed owner preserves nonzero completion and callback failure terminates ownership", async () => { @@ -148,15 +152,16 @@ test("cancellation preserves the owner's graceful cleanup beyond five seconds", (await Bun.file(file).text()).replace("PLACEHOLDER", marker) ); const controller = new AbortController(); - await expect( + await expectClosedRefusal( serveNativeProjectGraph({ ...opts, signal: controller.signal, onReady: async () => { setTimeout(() => controller.abort(), 20); }, - }) - ).rejects.toThrow("interrupted"); + }), + "interrupted" + ); expect(await Bun.file(marker).text()).toBe("cleaned"); }, 15_000); diff --git a/tests/native-project-recovery.test.ts b/tests/native-project-recovery.test.ts index 4a0b7dbd..4bf9ae25 100644 --- a/tests/native-project-recovery.test.ts +++ b/tests/native-project-recovery.test.ts @@ -14,6 +14,7 @@ import { retireNativeRecoveredPublisher, verifyNativeFrontendRecovery, } from "../src/backends/native-project-recovery.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -150,14 +151,16 @@ test("shared recovery refuses foreign leases and retains uncertainty on failed r { ...lease, namespace: "f".repeat(64) }, { ...lease, planId: "f".repeat(64) }, ]) { - await expect( - verifyNativeFrontendRecovery({ ...options, httpsLease: foreign }) - ).rejects.toThrow("cannot prove"); + await expectClosedRefusal( + verifyNativeFrontendRecovery({ ...options, httpsLease: foreign }), + "cannot prove" + ); } expect(recoveries).toBe(0); - await expect( - verifyNativeFrontendRecovery({ ...options, httpsPort: null }) - ).rejects.toThrow("cannot prove"); + await expectClosedRefusal( + verifyNativeFrontendRecovery({ ...options, httpsPort: null }), + "cannot prove" + ); await expect(verifyNativeFrontendRecovery(options)).rejects.toThrow( "exact lease retirement uncertain" ); @@ -166,9 +169,10 @@ test("shared recovery refuses foreign leases and retains uncertainty on failed r const changed = structuredClone(f.receipt); changed.observations["container:app"].state = "present"; f.events.length = 0; - await expect( - verifyNativeFrontendRecovery({ ...options, inspect: async () => changed }) - ).rejects.toThrow("cannot prove"); + await expectClosedRefusal( + verifyNativeFrontendRecovery({ ...options, inspect: async () => changed }), + "cannot prove" + ); expect(recoveries).toBe(1); expect(f.events).toEqual([]); }); @@ -197,13 +201,14 @@ test("recovery cleans owned lifecycle only after graph, authority and port proof const changed = structuredClone(f.receipt); changed.observations["container:app"].state = "present"; f.events.length = 0; - await expect( + await expectClosedRefusal( verifyNativeFrontendRecovery({ ...f.opts, inspect: async () => changed, cleanupLifecycle, - }) - ).rejects.toThrow("cannot prove"); + }), + "cannot prove" + ); expect(f.events).not.toContain("lifecycle"); }); @@ -211,18 +216,20 @@ test("changed graph, live effect, or failed legacy inventory refuses recovery", const f = await fixture(); const changed = structuredClone(f.receipt); changed.observations["container:app"].state = "present"; - await expect( + await expectClosedRefusal( verifyNativeFrontendRecovery({ ...f.opts, inspect: async () => changed, - }) - ).rejects.toThrow("cannot prove"); - await expect( + }), + "cannot prove" + ); + await expectClosedRefusal( verifyNativeFrontendRecovery({ ...f.opts, invoke: async () => ({ authority: { present: true } }), - }) - ).rejects.toThrow("cannot prove"); + }), + "cannot prove" + ); await expect( verifyNativeFrontendRecovery({ ...f.opts, @@ -238,7 +245,8 @@ test("changed graph, live effect, or failed legacy inventory refuses recovery", lstat(join(f.root, "native-https/owner.lock")) ).rejects.toMatchObject({ code: "ENOENT" }); await writeFile(join(f.lifecycle, "state.json"), '{"entries":[{}]}'); - await expect(verifyNativeFrontendRecovery(f.opts)).rejects.toThrow( + await expectClosedRefusal( + verifyNativeFrontendRecovery(f.opts), "cannot prove" ); }); @@ -248,19 +256,22 @@ test("recovery preserves an occupied or unverifiable HTTPS lock", async () => { const lock = join(f.root, "native-https/owner.lock"); await mkdir(lock, { recursive: true, mode: 0o700 }); await writeFile(join(lock, "owned-state"), "keep"); - await expect(verifyNativeFrontendRecovery(f.opts)).rejects.toThrow( + await expectClosedRefusal( + verifyNativeFrontendRecovery(f.opts), "cannot prove" ); await lstat(join(lock, "owned-state")); await rm(join(lock, "owned-state")); await chmod(lock, 0o755); - await expect(verifyNativeFrontendRecovery(f.opts)).rejects.toThrow( + await expectClosedRefusal( + verifyNativeFrontendRecovery(f.opts), "cannot prove" ); await chmod(lock, 0o700); - await expect( - verifyNativeFrontendRecovery({ ...f.opts, httpsPort: null }) - ).rejects.toThrow("cannot prove"); + await expectClosedRefusal( + verifyNativeFrontendRecovery({ ...f.opts, httpsPort: null }), + "cannot prove" + ); await lstat(lock); }); @@ -291,12 +302,13 @@ test("publisher retirement requests the exact run and owner, and rejects weak ac "--json", ], ]); - await expect( + await expectClosedRefusal( retireNativeRecoveredPublisher({ runtime: f.opts.runtime, scope: f.opts.scope, run: f.opts.run, invoke: async () => ({ publisher_retired: true }), - }) - ).rejects.toThrow("cannot prove"); + }), + "cannot prove" + ); }); diff --git a/tests/native-project-restart.test.ts b/tests/native-project-restart.test.ts index 4bf9424b..c6450adb 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -27,6 +27,11 @@ import { saveNativeRestartIntent, withNativeRestartLock, } from "../src/backends/native-project-run.ts"; +import { ClosedCliError } from "../src/lib/cli-result.ts"; +import { + expectClosedRefusal, + expectCodedRefusal, +} from "./helpers/closed-refusal.ts"; const run = { run: "a".repeat(32), @@ -296,6 +301,17 @@ test("legacy unknown selections refuse and omitted flags preserve saved values", expect(() => nativeRestartSelection({ run: { ...run, profiles: undefined } }) ).toThrow("legacy"); + // Restart preflight refusals are closed: one line, no stack, in the CLI. + let refusal: unknown; + try { + nativeRestartSelection({ run, envName: "other" }); + } catch (error: unknown) { + refusal = error; + } + expect(refusal).toBeInstanceOf(ClosedCliError); + expect(String(refusal)).toContain( + "cannot change the startup environment or profiles" + ); }); test("preflight compares actual reviewed identity before cleanup eligibility", async () => { @@ -361,7 +377,7 @@ test("preflight compares actual reviewed identity before cleanup eligibility", a ); expect(calls).toEqual(["review"]); // The native receipt, not the immutable ownership plan, records current routes. - await expect( + await expectClosedRefusal( preflightNativeRestart({ ...options, dependencies: { @@ -374,8 +390,9 @@ test("preflight compares actual reviewed identity before cleanup eligibility", a projectArgs: [], }), }, - }) - ).rejects.toThrow("compatibility changed"); + }), + "compatibility changed" + ); }); test("preflight refuses a stale host listener before restart cleanup", async () => { @@ -399,7 +416,7 @@ test("preflight refuses a stale host listener before restart cleanup", async () lifecycleHostEnvironment: {}, effectiveEnvName: "qa", }; - await expect( + await expectClosedRefusal( preflightNativeRestart({ runtime: { binary: join(directory, "hack-native"), home: "/candidate" }, scope, @@ -447,8 +464,9 @@ test("preflight refuses a stale host listener before restart cleanup", async () projectArgs: [], }), }, - }) - ).rejects.toThrow("host dependency listener is unavailable or changed"); + }), + "host dependency listener is unavailable or changed" + ); expect(calls).toEqual([ "runtime status", "runtime probe", @@ -492,7 +510,7 @@ test("restart preflight refuses failed or unknown admission before reading envir ); for (const admission of [{ admitted: false }, {}, null]) { const calls: string[] = []; - await expect( + await expectClosedRefusal( preflightNativeRestart({ runtime: { binary: "/unused", home: "/candidate" }, scope, @@ -507,8 +525,9 @@ test("restart preflight refuses failed or unknown admission before reading envir throw new Error("environment should not be read"); }, }, - }) - ).rejects.toThrow("admission failed before cleanup"); + }), + "admission failed before cleanup" + ); expect(calls).toEqual(["status", "probe"]); } }); @@ -741,7 +760,7 @@ test("explicit stopped recovery refuses incomplete, foreign or missing retained ]) { const f = fixture(); const calls: string[] = []; - await expect( + await expectClosedRefusal( restartNativeProject({ ...f.options, preflight: async () => @@ -756,8 +775,9 @@ test("explicit stopped recovery refuses incomplete, foreign or missing retained }, }, }), - }) - ).rejects.toThrow("cannot confirm the stopped retained graph"); + }), + "cannot confirm the stopped retained graph" + ); expect(calls).toEqual(["inspect"]); expect(f.events).toEqual([]); expect(f.state.pending).toBeNull(); @@ -773,7 +793,7 @@ test("explicit stopped recovery rechecks compute and retained data after review" for (const changed of ["compute", "data"]) { const f = fixture(); let inspections = 0; - await expect( + await expectClosedRefusal( restartNativeProject({ ...f.options, preflight: async () => @@ -799,8 +819,9 @@ test("explicit stopped recovery rechecks compute and retained data after review" }, }, }), - }) - ).rejects.toThrow("stopped recovery changed during review"); + }), + "stopped recovery changed during review" + ); expect(inspections).toBe(2); expect(f.events).toEqual([]); expect(f.state.pending).toBeNull(); @@ -814,7 +835,7 @@ test("frontend recovery on an active graph still requires live host listeners", const listener = await listenerIntentFixture(); try { const f = fixture(); - await expect( + await expectCodedRefusal( restartNativeProject({ ...f.options, preflight: async () => @@ -835,8 +856,10 @@ test("frontend recovery on an active graph still requires live host listeners", : await listener.options.dependencies!.invoke!(options), }, }), - }) - ).rejects.toThrow("bounded explicit listener selection; values omitted"); + }), + "E_CONFIG_INVALID", + "bounded explicit listener selection; values omitted" + ); expect(listener.calls).toEqual([ "runtime status", "runtime probe", @@ -929,7 +952,7 @@ test("invalid retained image provenance refuses stopped preflight before tag res try { const f = fixture(); const calls: string[] = []; - await expect( + await expectClosedRefusal( restartNativeProject({ ...f.options, preflight: async () => @@ -959,8 +982,9 @@ test("invalid retained image provenance refuses stopped preflight before tag res }, }, }), - }) - ).rejects.toThrow("retained image provenance is invalid"); + }), + "retained image provenance is invalid" + ); expect(calls).not.toContain("ensure-image"); expect(f.events).toEqual([]); expect(f.state.pending).toBeNull(); @@ -1134,7 +1158,7 @@ test("cleaned intent cannot bypass live, uncertain, foreign or malformed retaine for (const observed of invalid) { listener.calls.length = 0; const f = fixture({ run, finalization: token, phase: "cleaned" }); - await expect( + await expectClosedRefusal( restartNativeProject({ ...f.options, preflight: async (selected, { cleanedRetry }) => @@ -1151,14 +1175,15 @@ test("cleaned intent cannot bypass live, uncertain, foreign or malformed retaine }, }), dependencies: { ...f.options.dependencies, load: async () => run }, - }) - ).rejects.toThrow("cannot confirm the stopped retained graph"); + }), + "cannot confirm the stopped retained graph" + ); expect(listener.calls).toEqual(["graph inspect"]); expect(f.events).toEqual([]); expect(f.state.pending?.phase).toBe("cleaned"); } const f = fixture({ run, finalization: token, phase: "cleaned" }); - await expect( + await expectClosedRefusal( restartNativeProject({ ...f.options, preflight: async (selected, { cleanedRetry }) => @@ -1174,8 +1199,9 @@ test("cleaned intent cannot bypass live, uncertain, foreign or malformed retaine }, }), dependencies: { ...f.options.dependencies, load: async () => run }, - }) - ).rejects.toThrow("cannot confirm the stopped retained graph"); + }), + "cannot confirm the stopped retained graph" + ); expect(f.events).toEqual([]); } finally { await rm(listener.directory, { recursive: true, force: true }); @@ -1186,7 +1212,7 @@ test("active restart still refuses absent or wrong-executable listeners before c const listener = await listenerIntentFixture(); try { const f = fixture(); - await expect( + await expectCodedRefusal( restartNativeProject({ ...f.options, preflight: async (selected, { cleanedRetry }) => { @@ -1197,8 +1223,10 @@ test("active restart still refuses absent or wrong-executable listeners before c cleanedRetry, }); }, - }) - ).rejects.toThrow("bounded explicit listener selection; values omitted"); + }), + "E_CONFIG_INVALID", + "bounded explicit listener selection; values omitted" + ); expect(listener.calls).toEqual([ "runtime status", "runtime probe", @@ -1229,7 +1257,7 @@ test("malformed dependency intent refuses cleaned retry without capture or start JSON.stringify({ version: 1, dependencies: [binding] }) ); const f = fixture({ run, finalization: token, phase: "cleaned" }); - await expect( + await expectClosedRefusal( restartNativeProject({ ...f.options, preflight: async (selected, { cleanedRetry }) => @@ -1238,8 +1266,9 @@ test("malformed dependency intent refuses cleaned retry without capture or start run: selected, cleanedRetry, }), - }) - ).rejects.toThrow("dependency intent is invalid; values omitted"); + }), + "dependency intent is invalid; values omitted" + ); expect(listener.calls).toEqual([ "graph inspect", "runtime status", @@ -1367,7 +1396,7 @@ test("active legacy restart preserves adapted labels and rechecks authenticated }), }); if (drift) { - await expect(result).rejects.toThrow("active review identity changed"); + await expectClosedRefusal(result, "active review identity changed"); expect(f.events).toEqual([]); } else { expect(await result).toBe(0); diff --git a/tests/native-project-restore.test.ts b/tests/native-project-restore.test.ts index 7e539d6b..c1dbee85 100644 --- a/tests/native-project-restore.test.ts +++ b/tests/native-project-restore.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { selectNativeProjectRestore } from "../src/backends/native-project-restore.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const saved = { run: "a".repeat(32), @@ -59,22 +60,24 @@ test("restore binds the same run to exact owner plan and generation", async () = }); test("changed retained identity refuses without adopting volumes", async () => { for (const key of ["run", "owner", "namespace", "plan", "generation"]) { - await expect( + await expectClosedRefusal( selectNativeProjectRestore({ ...options, restore: saved, invoke: async () => ({ ...selected, [key]: "changed" }), - }) - ).rejects.toThrow("selection changed"); + }), + "selection changed" + ); } - await expect( + await expectClosedRefusal( selectNativeProjectRestore({ ...options, restore: saved, review: { ...options.review, planId: "f".repeat(64) }, invoke: async () => selected, - }) - ).rejects.toThrow("compatibility changed"); + }), + "compatibility changed" + ); }); test("changed source review retains the old cache publication after native compatibility proof", async () => { diff --git a/tests/native-project-retained-images.test.ts b/tests/native-project-retained-images.test.ts index 45165335..39129db4 100644 --- a/tests/native-project-retained-images.test.ts +++ b/tests/native-project-retained-images.test.ts @@ -4,6 +4,7 @@ import { selectNativeRetainedImages, } from "../src/backends/native-project-retained-images.ts"; import { verifyNativeActiveReview } from "../src/backends/native-project-review.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; const saved = { run: "a".repeat(32), @@ -213,7 +214,7 @@ test("active native selection and a substituted fresh receipt cannot authenticat ).rejects.toThrow(); } let inspections = 0; - await expect( + await expectClosedRefusal( selectNativeActiveImages({ ...options, invoke: async ({ args }) => { @@ -238,8 +239,9 @@ test("active native selection and a substituted fresh receipt cannot authenticat }, }; }, - }) - ).rejects.toThrow("active image identity changed"); + }), + "active image identity changed" + ); }); test("native generation, container and boot are rechecked after image selection", async () => { const selected = await selectNativeActiveImages({ @@ -248,7 +250,7 @@ test("native generation, container and boot are rechecked after image selection" args[1] === "inspect" ? activeObservation() : activeSelection(), }); for (const field of ["generation", "container", "boot"]) { - await expect( + await expectClosedRefusal( verifyNativeActiveReview({ ...options, retained: saved, @@ -257,13 +259,14 @@ test("native generation, container and boot are rechecked after image selection" ...activeSelection(), [field]: field === "boot" ? "new-boot" : "4".repeat(64), }), - }) - ).rejects.toThrow("active review identity changed"); + }), + "active review identity changed" + ); } }); test("image drift between inspections refuses even when the mocked native proof is unchanged", async () => { let inspections = 0; - await expect( + await expectClosedRefusal( selectNativeActiveImages({ ...options, invoke: async ({ args }) => { @@ -288,8 +291,9 @@ test("image drift between inspections refuses even when the mocked native proof }, }; }, - }) - ).rejects.toThrow("active image selection changed"); + }), + "active image selection changed" + ); }); test("fresh input never observes retained state", async () => { expect( @@ -347,9 +351,10 @@ test("stale ownership and uncertain compute or data refuse image reuse", async ( }, }, ]) { - await expect( - selectNativeRetainedImages({ ...options, invoke: async () => changed }) - ).rejects.toThrow("image selection changed"); + await expectClosedRefusal( + selectNativeRetainedImages({ ...options, invoke: async () => changed }), + "image selection changed" + ); } }); test("malformed provenance or image and mismatched resource keys refuse reuse", async () => { @@ -359,7 +364,7 @@ test("malformed provenance or image and mismatched resource keys refuse reuse", "original_compose_sha256", "normalized_compose_sha256", ]) { - await expect( + await expectClosedRefusal( selectNativeRetainedImages({ ...options, invoke: async () => ({ @@ -372,8 +377,9 @@ test("malformed provenance or image and mismatched resource keys refuse reuse", }, }, }), - }) - ).rejects.toThrow("provenance is invalid"); + }), + "provenance is invalid" + ); } for (const resource of [ { ...baseline.receipt.resources["container:web"], image: "redis:latest" }, diff --git a/tests/native-project-retained-startup.test.ts b/tests/native-project-retained-startup.test.ts new file mode 100644 index 00000000..590540ee --- /dev/null +++ b/tests/native-project-retained-startup.test.ts @@ -0,0 +1,109 @@ +import { expect, test } from "bun:test"; +import { + preflightNativeRetainedStartup, + verifyNativeResumedRetainedGraph, + verifyNativeRetainedMapping, +} from "../src/backends/native-project-retained-startup.ts"; +import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; +import { expectClosedRefusal } from "./helpers/closed-refusal.ts"; + +/** + * Retained-startup refusals are integrity or ownership outcomes: closed, + * value-free messages that the CLI prints without a stack. + */ + +const runtime = { binary: "/fixture/native", home: "/fixture/home" }; +const scope = { + projectRoot: "/fixture/project", + projectDir: "/fixture/project/.hack", + nativeHome: "/fixture/home", + branch: null, +}; +const run: NativeProjectRun = { + run: "1".repeat(32), + owner: "2".repeat(32), + namespace: "3".repeat(64), + planId: "4".repeat(64), +}; +const eligible = { + version: 1, + run: run.run, + owner: run.owner, + namespace: run.namespace, + plan: run.planId, + live_resources_verified: false, + runtime_phase: "stopped", + selection: "5".repeat(64), +}; + +test("eligible retained startup selects the retained run", async () => { + expect( + await preflightNativeRetainedStartup({ + runtime, + projectRoot: scope.projectRoot, + run, + invoke: async () => eligible, + }) + ).toEqual({ + runtimePhase: "stopped", + flags: [ + "--expect-retained-run", + run.run, + "--expect-retained-selection", + eligible.selection, + ], + }); +}); + +test("changed retained eligibility refuses with a closed message", async () => { + await expectClosedRefusal( + preflightNativeRetainedStartup({ + runtime, + projectRoot: scope.projectRoot, + run, + invoke: async () => ({ ...eligible, owner: "9".repeat(32) }), + }), + "Native retained startup eligibility changed; no hooks or runtime start were requested." + ); +}); + +test("a canceled retained startup refuses before any request", async () => { + let calls = 0; + await expectClosedRefusal( + preflightNativeRetainedStartup({ + runtime, + projectRoot: scope.projectRoot, + run, + signal: AbortSignal.abort(), + invoke: async () => { + calls++; + return eligible; + }, + }), + "Native retained startup was canceled" + ); + expect(calls).toBe(0); +}); + +test("a retained mapping changed before runtime start refuses", async () => { + await expectClosedRefusal( + verifyNativeRetainedMapping({ + run, + scope, + load: async () => ({ ...run, owner: "9".repeat(32) }), + }), + "Native retained run mapping changed before runtime start; no start was requested." + ); +}); + +test("a resumed retained graph that differs refuses", async () => { + await expectClosedRefusal( + verifyNativeResumedRetainedGraph({ + runtime, + projectRoot: scope.projectRoot, + run, + invoke: async () => ({ journal_incomplete: false, receipt: {} }), + }), + "Native resumed retained graph differs; no graph restore was requested." + ); +}); diff --git a/tests/native-project-review.test.ts b/tests/native-project-review.test.ts index 4e5d52d0..ae6e5d89 100644 --- a/tests/native-project-review.test.ts +++ b/tests/native-project-review.test.ts @@ -11,6 +11,10 @@ import { withNativeProjectReview, } from "../src/backends/native-project-review.ts"; import type { invokeNativeRuntime } from "../src/backends/native-runtime-client.ts"; +import { + expectClosedRefusal, + expectCodedRefusal, +} from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -79,17 +83,49 @@ test("an original edit after env preparation stops before the callback", async ( const options = await fixture(); await Bun.write(options.composeFile, '{"services":{"different":{}}}'); let ran = false; - await expect( + await expectClosedRefusal( withNativeProjectReview({ ...options, run: async () => { ran = true; }, - }) - ).rejects.toThrow("input changed"); + }), + "input changed" + ); expect(ran).toBe(false); }); +test("a Compose file outside the project and an invalid reviewed identity refuse without a stack", async () => { + const options = await fixture(); + await expectCodedRefusal( + selectNativeProjectReviewIdentity({ + ...options, + composeFile: join(dirname(options.projectRoot), "elsewhere.yml"), + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + "Native project review requires an owned original Compose file." + ); + const invoke: typeof invokeNativeRuntime = async (request) => + request.args.includes("--normalized-file") + ? { plan_id: "not-a-digest", plan: { namespace: "a".repeat(64) } } + : { + plan: { + namespace: "a".repeat(64), + compose_sha256: options.input.originalSha256, + }, + }; + await expectClosedRefusal( + withNativeProjectReview({ + ...options, + invoke, + run: async () => { + throw new Error("must not run"); + }, + }), + "Native project returned an invalid reviewed identity." + ); +}); + test("failed admission removes the temporary public input", async () => { const options = await fixture(); let file = ""; @@ -243,7 +279,7 @@ test("legacy review rejects changed native selection before unbranched review", for (const key of ["run", "owner", "namespace", "plan", "generation"]) { const { options, calls, selected, invoke } = await legacyFixture(); selected[key] = "invalid"; - await expect( + await expectClosedRefusal( withNativeProjectReview({ ...options, branch: "feature-a", @@ -252,8 +288,9 @@ test("legacy review rejects changed native selection before unbranched review", run: async () => { throw new Error("unexpected callback"); }, - }) - ).rejects.toThrow("restore selection changed"); + }), + "restore selection changed" + ); expect(calls).toHaveLength(2); } }); @@ -267,7 +304,7 @@ test("legacy review rejects a different project, namespace or edited original wi } return await invoke(request); }; - await expect( + await expectClosedRefusal( withNativeProjectReview({ ...options, branch: "feature-a", @@ -276,8 +313,9 @@ test("legacy review rejects a different project, namespace or edited original wi run: async () => { throw new Error("unexpected callback"); }, - }) - ).rejects.toThrow("retained project review changed"); + }), + "retained project review changed" + ); expect(calls).toHaveLength(3); } }); @@ -285,7 +323,7 @@ test("legacy review rejects a different project, namespace or edited original wi test("legacy review cleans temporary input and refuses selection drift after review", async () => { const { options, selected, invoke } = await legacyFixture(); let temporary = ""; - await expect( + await expectClosedRefusal( withNativeProjectReview({ ...options, branch: "feature-a", @@ -305,8 +343,9 @@ test("legacy review cleans temporary input and refuses selection drift after rev invoke, }); }, - }) - ).rejects.toThrow("restore selection changed"); + }), + "restore selection changed" + ); expect(await Bun.file(temporary).exists()).toBe(false); }); @@ -363,7 +402,7 @@ test("early identity proof is rechecked after route preparation before normalize }); selected[key] = "1".repeat(key === "generation" ? 64 : 32); calls.splice(0); - await expect( + await expectClosedRefusal( withNativeProjectReview({ ...options, branch: "feature-a", @@ -373,8 +412,9 @@ test("early identity proof is rechecked after route preparation before normalize run: async () => { throw new Error("unexpected admission"); }, - }) - ).rejects.toThrow("selection changed"); + }), + "selection changed" + ); expect(calls.some((args) => args.includes("--normalized-file"))).toBe( false ); @@ -456,27 +496,30 @@ test("active proof refuses every tuple drift after compatibility and unavailable key === "boot" ? "another-boot" : "2".repeat(key === "run" || key === "owner" ? 32 : 64); - await expect( + await expectClosedRefusal( verifyNativeActiveReview({ ...options, retained, proof: identity.activeProof, invoke, - }) - ).rejects.toThrow("changed"); + }), + "changed" + ); } for (const services of [{}, { web: { active: false } }]) { const { options, calls, unbranched, invoke } = await legacyFixture(); unbranched.services = services; - await expect( + await expectCodedRefusal( selectNativeProjectReviewIdentity({ ...options, branch: "feature-a", retained, retainedMode: "active", invoke, - }) - ).rejects.toThrow("no supported service authority"); + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + "no supported service authority" + ); expect(calls).toHaveLength(1); } }); diff --git a/tests/native-project-routing.test.ts b/tests/native-project-routing.test.ts index 5fc2d98e..24088cbf 100644 --- a/tests/native-project-routing.test.ts +++ b/tests/native-project-routing.test.ts @@ -82,6 +82,19 @@ test("active reviewed routes request pool allocation with matching existing prob ).toThrow(); } expect(() => reviewedNativeRoutes({ ...opts, capacity: 1 })).toThrow(); + let refusal: unknown; + try { + reviewedNativeRoutes({ + ...opts, + specs: { ...opts.specs, z: { labels } }, + }); + } catch (error: unknown) { + refusal = error; + } + expect(refusal).toMatchObject({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: expect.stringContaining("reviewed Caddy labels"), + }); expect(() => reviewedNativeRoutes({ ...opts, diff --git a/tests/native-project-run.test.ts b/tests/native-project-run.test.ts index 50ddb1e7..ace1adad 100644 --- a/tests/native-project-run.test.ts +++ b/tests/native-project-run.test.ts @@ -14,9 +14,14 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadNativeProjectRun as load, + normalizeNativeProfiles, removeNativeProjectRun as remove, saveNativeProjectRun as save, } from "../src/backends/native-project-run.ts"; +import { + expectClosedRefusal, + expectCodedThrow, +} from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -52,9 +57,10 @@ test("exclusive project/branch mapping roundtrips and exact cleanup preserves ot branch: "other", run: { ...run, run: "e".repeat(32) }, }); - await expect( - remove({ ...opts, expected: { ...run, owner: "e".repeat(32) } }) - ).rejects.toThrow(); + await expectClosedRefusal( + remove({ ...opts, expected: { ...run, owner: "e".repeat(32) } }), + "owned by another run" + ); expect(await load(opts)).toEqual(run); await remove({ ...opts, expected: run }); expect(await load(opts)).toBeNull(); @@ -122,7 +128,10 @@ test("malformed ownership, symlink storage and foreign contents refuse", async ( const name = (await readdir(dir)).find((x) => x.endsWith(".json")); expect(name).toBeDefined(); await writeFile(join(dir, name ?? "absent"), "{}"); - await expect(load(opts)).rejects.toThrow(); + await expectClosedRefusal( + load(opts), + "Native project run mapping is unsafe, changed, or owned by another run" + ); }); test("repeated confirmed retirement is idempotent without deleting a replacement", async () => { @@ -283,12 +292,29 @@ test("restart operation lock excludes overlap and releases at readiness", async ); const opts = await fixture(); await withNativeRestartLock(opts, async (release) => { - await expect( - withNativeRestartLock(opts, () => Promise.resolve()) - ).rejects.toThrow("already owned"); + await expectClosedRefusal( + withNativeRestartLock(opts, () => Promise.resolve()), + "already owned" + ); await release(); expect( await withNativeRestartLock(opts, () => Promise.resolve("next")) ).toBe("next"); }); }); + +test("unbounded profile selections are a coded configuration refusal", () => { + expect(normalizeNativeProfiles(["b", "a", "b"])).toEqual(["a", "b"]); + for (const profiles of [ + ["x".repeat(300)], + [""], + ["bad\u0000name"], + Array.from({ length: 65 }, (_, index) => `p${index}`), + ]) { + expectCodedThrow( + () => normalizeNativeProfiles(profiles), + "E_CONFIG_INVALID", + "Native profiles require at most 64 bounded nonempty names." + ); + } +}); diff --git a/tests/native-project-source-preflight.test.ts b/tests/native-project-source-preflight.test.ts index 064fb695..517336a8 100644 --- a/tests/native-project-source-preflight.test.ts +++ b/tests/native-project-source-preflight.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { preflightNativeProjectSource } from "../src/backends/native-project-source-preflight.ts"; +import { ClosedCliError } from "../src/lib/cli-result.ts"; const runtime = { binary: "/candidate/hack-native", home: "/candidate/home" }; @@ -31,13 +32,14 @@ test("invalid and refused source admission never counts as startup permission", { source_admitted: false, pool_initialized: true }, { source_admitted: true }, ]) { - await expect( - preflightNativeProjectSource({ - runtime, - projectRoot: "/project", - invoke: async () => result, - }) - ).rejects.toThrow("invalid receipt"); + const refusal = await preflightNativeProjectSource({ + runtime, + projectRoot: "/project", + invoke: async () => result, + }).catch((error: unknown) => error); + // A closed refusal: the CLI prints this one line without a stack. + expect(refusal).toBeInstanceOf(ClosedCliError); + expect(String(refusal)).toContain("invalid receipt"); } await expect( preflightNativeProjectSource({ diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index c365d1f4..ab55aff2 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -24,13 +24,17 @@ import { preflightNativeRestart } from "../src/backends/native-project-restart-p import { selectNativeRetainedImages } from "../src/backends/native-project-retained-images.ts"; import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; import { + NATIVE_CONFIGURATION_REFUSAL, + NATIVE_SHARED_SOURCE_REFUSAL, nativeReadinessArgs, parseNativeHttpsSelection, + prepareNativeProjectServices, reviewedNativeHttpsRoutes, startNativeProject, verifyHttpsRoutes, } from "../src/backends/native-project-start.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { ClosedCliError, HackCliError } from "../src/lib/cli-result.ts"; import { type ComposeEnvFingerprintRecord, evaluateComposeServiceEnvFreshness, @@ -484,20 +488,204 @@ test("foreground partial shutdown is reported without selecting failed-start rec expect(events).not.toContain("remove"); }); +test.each([ + { build: "." }, + { ports: ["8080:80"] }, + { restart: "always" }, +])("an unsupported service setting %j is a coded configuration refusal", (setting) => { + const refusal = (() => { + try { + prepareNativeProjectServices({ + normalizedComposeJson: JSON.stringify({ + services: { web: { image: "busybox", ...setting } }, + }), + } as NativeProjectInput); + } catch (error: unknown) { + return error; + } + })(); + expect(refusal).toBeInstanceOf(HackCliError); + expect(refusal).toMatchObject({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: NATIVE_CONFIGURATION_REFUSAL, + }); +}); + +test("a canceled start reports cancellation, not the configuration refusal", async () => { + const { opts, events } = await fixture(); + const refusal = await startNativeProject({ + ...opts, + signal: AbortSignal.abort(), + }).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(ClosedCliError); + expect(refusal).not.toBeInstanceOf(HackCliError); + expect(String(refusal)).toContain("Native foreground up was canceled"); + expect(String(refusal)).not.toContain(NATIVE_CONFIGURATION_REFUSAL); + expect(events).toEqual([]); +}); + +/** An integrity or ownership refusal: closed, and never the configuration refusal. */ +function expectIntegrityRefusal(refusal: unknown, text: string): void { + expect(refusal).toBeInstanceOf(ClosedCliError); + expect(refusal).not.toBeInstanceOf(HackCliError); + expect(refusal).toHaveProperty("message", expect.stringContaining(text)); + expect(String(refusal)).not.toContain(NATIVE_CONFIGURATION_REFUSAL); + expect(String(refusal)).not.toContain("E_NATIVE_PROJECT_UNSUPPORTED"); +} + +test("an unconfirmed image selection reply is an integrity refusal, not a configuration refusal", async () => { + const { opts } = await fixture(); + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => ({ + ...(await prepare(request)), + normalizedComposeJson: JSON.stringify({ + services: { web: { image: "redis:latest" } }, + }), + }); + const invoke = opts.dependencies.invoke!; + opts.dependencies.invoke = async (request) => + request.args[1] === "ensure-image" + ? { image_id: "not-a-content-id" } + : await invoke(request); + expectIntegrityRefusal( + await startNativeProject(opts).catch((error: unknown) => error), + "Native foreground up could not confirm the runtime image selection; inspect owned state before retrying." + ); +}); + +test("an invalid host dependency plan reply is an integrity refusal", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + opts.dependencies.invoke = async (request) => + request.args[1] === "dependency-plan" + ? { dependency_plan_id: "not-a-digest" } + : await invoke(request); + expectIntegrityRefusal( + await startNativeProject(opts).catch((error: unknown) => error), + "Native foreground up could not confirm the host dependency plan; inspect owned state before retrying." + ); + expect(events).not.toContain("save"); +}); + +test("a ready receipt for another namespace is an integrity refusal", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + let serving = false; + const serve = opts.dependencies.serve!; + opts.dependencies.serve = async (request) => { + serving = true; + try { + return await serve(request); + } finally { + serving = false; + } + }; + opts.dependencies.invoke = async (request) => { + const reply = await invoke(request); + if (request.args[1] === "inspect" && serving) { + const graph = reply as { receipt: Record }; + return { + ...graph, + receipt: { ...graph.receipt, namespace: "0".repeat(64) }, + }; + } + return reply; + }; + expectIntegrityRefusal( + await startNativeProject(opts).catch((error: unknown) => error), + "Native foreground up could not confirm the startup receipt; inspect owned state before retrying." + ); + expect(events).not.toContain("save"); +}); + +test("a run mapping owned by another run is an ownership refusal and is not removed", async () => { + const { opts, events } = await fixture(); + const invoke = opts.dependencies.invoke!; + let ready = false; + const serve = opts.dependencies.serve!; + opts.dependencies.serve = async (request) => { + try { + return await serve(request); + } finally { + ready = true; + } + }; + opts.dependencies.invoke = async (request) => { + const reply = await invoke(request); + if (request.args[1] === "inspect" && ready) { + // After serve, the final receipt names a different owner than the one + // the published mapping recorded at readiness. + const graph = reply as { receipt: Record }; + return { + ...graph, + receipt: { ...graph.receipt, owner: "9".repeat(32), phase: "removed" }, + }; + } + return reply; + }; + expectIntegrityRefusal( + await startNativeProject(opts).catch((error: unknown) => error), + "Native run mapping is owned by another run; it was not removed. Inspect owned state before retrying." + ); + expect(events).toContain("save"); + expect(events).not.toContain("remove"); +}); + +test("a missing or unparseable HTTPS verification response is a verification failure", async () => { + const plan = { + services: { + web: { + routing: { hostnames: ["web.hack"] }, + healthcheck: { native_http: { path: "/health" } }, + }, + }, + }; + const frontend = (response: { statusCode?: number; location?: string }) => ({ + caPath: "/public", + httpsPort: 18_443, + exited: new Promise<{ component: string; code: number }>(() => {}), + releaseAfterCleanup: async () => {}, + verifyHostname: async () => response as { statusCode: number }, + }); + expectIntegrityRefusal( + await verifyHttpsRoutes(frontend({}), plan, new Set(["web"])).catch( + (error: unknown) => error + ), + "Native HTTPS verification failed (NO_RESPONSE); peer values omitted." + ); + expectIntegrityRefusal( + await verifyHttpsRoutes( + frontend({ statusCode: 307, location: "https://[" }), + plan, + new Set(["web"]) + ).catch((error: unknown) => error), + "Native HTTPS verification failed (REDIRECT_TARGET_REFUSED); peer values omitted." + ); +}); + test("source opt-in and occupied mapping refuse before lifecycle or runtime effects", async () => { const { opts, events } = await fixture(); - await expect( - startNativeProject({ ...opts, sharedSource: false }) - ).rejects.toThrow("HACK_NATIVE_SHARED_SOURCE"); + const refusal = await startNativeProject({ + ...opts, + sharedSource: false, + }).catch((error: unknown) => error); + // A known refusal: stable code, closed message, printed without a stack. + expect(refusal).toBeInstanceOf(HackCliError); + expect(refusal).toMatchObject({ + code: "E_PERMISSION", + message: NATIVE_SHARED_SOURCE_REFUSAL, + }); opts.dependencies.load = async () => ({ run: "a".repeat(32), owner: "b".repeat(32), namespace: "c".repeat(64), planId: "d".repeat(64), }); - await expect(startNativeProject(opts)).rejects.toThrow( - "cannot change the retained run" + const retained = await startNativeProject(opts).catch( + (error: unknown) => error ); + expect(retained).toBeInstanceOf(ClosedCliError); + expect(String(retained)).toContain("cannot change the retained run"); expect(events).toEqual([]); }); test("caller cancellation is forwarded and lifecycle cleanup remains owned", async () => { diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts new file mode 100644 index 00000000..a89d0738 --- /dev/null +++ b/tests/native-refusal-cli.test.ts @@ -0,0 +1,543 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { beginNativeProjectFinalization } from "../src/backends/native-project-finalization.ts"; +import { + type NativeProjectRun, + saveNativeProjectRun, +} from "../src/backends/native-project-run.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * Known native refusals reachable from image-project `up`, `restart` and `ps` + * print one closed line through the real CLI: no `Error:` prefix and no stack. + * + * The stub runtime answers ` ` requests (for example + * `project plan`) from `/stub/-.out` and otherwise reports a + * stopped runtime, so each case scripts exactly the replies that reach the + * refusal it names. Nothing here starts a real runtime, Docker or a VM. + */ + +const roots: string[] = []; +const entrypoint = resolve("index.ts"); +/** A JavaScript stack frame as Bun prints it. */ +const STACK_FRAME = /^\s+at /m; +/** A source CLI spawn can take several seconds on a loaded runner. */ +const SPAWN_TIMEOUT_MS = 60_000; +const COMPOSE = JSON.stringify({ + services: { web: { image: `sha256:${"e".repeat(64)}` } }, +}); +const run: NativeProjectRun = { + run: "a".repeat(32), + owner: "b".repeat(32), + namespace: "c".repeat(64), + planId: "d".repeat(64), + profiles: [], + effectiveEnvName: null, + aws: null, +}; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture(compose = COMPOSE) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-refusal-cli-")) + ); + roots.push(root); + const project = join(root, "project"); + const projectDir = join(project, ".hack"); + const nativeHome = join(root, "native-home"); + const runtimeDir = join(root, "runtime"); + const stub = join(root, "stub"); + await mkdir(projectDir, { recursive: true }); + await mkdir(nativeHome, { mode: 0o700 }); + await mkdir(runtimeDir); + await mkdir(stub); + await writeFile(join(projectDir, "docker-compose.yml"), compose); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ + name: "refusal-fixture", + dev_host: "refusal-fixture.hack.local", + }) + ); + // `up` requires the bundled relay artifact next to the runtime binary. + await writeFile(join(runtimeDir, "hack-relay-guest"), "synthetic relay"); + const binary = join(runtimeDir, "native-runtime"); + await writeFile( + binary, + `#!/bin/sh +printf '%s\\n' "$*" >> '${stub}/calls' +if [ -f '${stub}/'"$3-$4"'.out' ]; then cat '${stub}/'"$3-$4"'.out'; exit 0; fi +if [ -f '${stub}/'"$3-$4"'.err' ]; then cat '${stub}/'"$3-$4"'.err' >&2; exit 1; fi +printf '%s' '{"code":"runtime_not_running"}' >&2 +exit 1 +`, + { mode: 0o700 } + ); + const scope = { projectRoot: project, projectDir, nativeHome, branch: null }; + const reply = async (request: string, value: unknown) => + await writeFile( + join(stub, `${request.replace(" ", "-")}.out`), + typeof value === "string" ? value : JSON.stringify(value) + ); + /** Make `request` fail with this closed native failure on stderr. */ + const fail = async (request: string, failure: Record) => + await writeFile( + join(stub, `${request.replace(" ", "-")}.err`), + JSON.stringify(failure) + ); + const calls = async () => + (await readFile(join(stub, "calls"), "utf8").catch(() => "")) + .split("\n") + .filter(Boolean) + .map((line) => line.split(" ").slice(2, 4).join(" ")); + const env = (extra: Record = {}) => + privateCliEnv({ + root, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: binary, + HACK_NATIVE_HOME: nativeHome, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + ...extra, + }, + }); + return { root, project, projectDir, scope, compose, reply, fail, calls, env }; +} + +type Fixture = Awaited>; + +async function cli(opts: { + readonly fixture: Fixture; + readonly args: readonly string[]; + readonly env?: Record; +}) { + const child = Bun.spawn( + [ + process.execPath, + entrypoint, + "--path", + opts.fixture.project, + ...opts.args, + ], + { + cwd: opts.fixture.root, + env: opts.fixture.env(opts.env), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: SPAWN_TIMEOUT_MS - 5000, + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, code, output: stdout + stderr }; +} + +/** The refusal is the whole report: one `ERROR:` line, no stack, no help. */ +function expectOneClosedLine( + result: Awaited>, + line: string +): void { + expect(result.code).toBe(1); + const errors = result.output + .split("\n") + .filter((text) => text.startsWith("ERROR:")); + expect(errors).toEqual([`ERROR: ${line}`]); + expect(result.output).not.toMatch(STACK_FRAME); + expect(result.output).not.toContain("Error: "); + expect(result.output).not.toContain("hack [options]"); +} + +/** Replies that carry an image-project `up` through review. */ +async function scriptUpThroughReview( + f: Fixture, + plan: Record = {} +) { + const sha = createHash("sha256").update(f.compose).digest("hex"); + await f.reply("runtime check-project-share", { + source_admitted: true, + pool_initialized: true, + }); + await f.reply("runtime up", {}); + await f.reply("project plan", { + plan_id: "f".repeat(64), + plan: { + namespace: run.namespace, + compose_sha256: sha, + enrollment_compatible: true, + services: { web: { active: true } }, + ...plan, + }, + }); +} + +const SHARED = { HACK_NATIVE_SHARED_SOURCE: "1" }; + +/** A mapping saved by a prior start, as `ps`, `up` and `restart` find it. */ +async function savedRun(f: Fixture) { + await saveNativeProjectRun({ ...f.scope, run }); +} + +/** The stopped retained graph the runtime reports for {@link run}. */ +const stoppedGraph = { + journal_incomplete: false, + receipt: { + run: run.run, + owner: run.owner, + namespace: run.namespace, + plan_id: run.planId, + phase: "stopped-data-retained", + resources: { + "container:web": { + kind: "container", + key: "web", + id: "f".repeat(64), + phase: "started", + }, + }, + }, + observations: { "container:web": { state: "absent" } }, +}; + +test( + "native-project-run: an unbounded profile is one coded line before any request", + async () => { + const f = await fixture(); + const result = await cli({ + fixture: f, + args: ["up", "--profile", "p".repeat(300)], + env: SHARED, + }); + expectOneClosedLine( + result, + "E_CONFIG_INVALID: Native profiles require at most 64 bounded nonempty names." + ); + expect(await f.calls()).toEqual([]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-run: an unsafe saved mapping is one closed line", + async () => { + const f = await fixture(); + await savedRun(f); + const dir = join(f.projectDir, ".internal", "native-runs"); + const name = (await readdir(dir)).find((entry) => entry.endsWith(".json")); + await writeFile(join(dir, name ?? "absent.json"), "{}"); + const result = await cli({ fixture: f, args: ["ps"] }); + expectOneClosedLine( + result, + "Native project run mapping is unsafe, changed, or owned by another run; inspect native state before recovery." + ); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-runtime-client: an unparseable runtime reply is one closed line", + async () => { + const f = await fixture(); + await savedRun(f); + await f.reply("graph inspect", "not-json"); + const result = await cli({ fixture: f, args: ["ps"] }); + expectOneClosedLine( + result, + "Native runtime returned an invalid control response." + ); + expect(await f.calls()).toEqual(["graph inspect"]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-dependencies: an invalid listener selection is one coded line", + async () => { + const f = await fixture(); + await scriptUpThroughReview(f); + const selection = join(f.root, "dependencies.json"); + await writeFile(selection, "{}", { mode: 0o600 }); + const result = await cli({ + fixture: f, + args: ["up"], + env: { ...SHARED, HACK_NATIVE_DEPENDENCIES: selection }, + }); + expectOneClosedLine( + result, + "E_CONFIG_INVALID: Native host dependencies require a bounded explicit listener selection; values omitted." + ); + expect(await f.calls()).toEqual(["runtime check-project-share"]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-review: changed project input is one closed line", + async () => { + const f = await fixture(); + await scriptUpThroughReview(f); + await f.reply("project plan", {}); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + "Native project input changed before review; prepare it again." + ); + expect(await f.calls()).toEqual([ + "runtime check-project-share", + "runtime up", + "project plan", + ]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-cache: an unverified source publication is one closed line", + async () => { + const f = await fixture(); + await scriptUpThroughReview(f, { + services: { web: { active: true, dependency_cache: {} } }, + }); + await f.reply("project publish-source", {}); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + "Native cache source publication did not return a verified revision." + ); + expect((await f.calls()).at(-1)).toBe("project publish-source"); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-start: an unconfirmed dependency plan is one closed integrity line", + async () => { + const f = await fixture(); + await scriptUpThroughReview(f); + await f.reply("graph dependency-plan", { dependency_plan_id: "x" }); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + "Native foreground up could not confirm the host dependency plan; inspect owned state before retrying." + ); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-retained-startup: changed retained eligibility is one closed line", + async () => { + const f = await fixture(); + await savedRun(f); + await f.reply("graph retained-preflight", {}); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + "Native retained startup eligibility changed; no hooks or runtime start were requested." + ); + expect(await f.calls()).toEqual(["graph retained-preflight"]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-restore: a changed restore selection is one closed line", + async () => { + const f = await fixture(); + await savedRun(f); + // The retained frontend finished; restore may proceed to review. + await ( + await beginNativeProjectFinalization({ scope: f.scope, run }) + ).complete(); + await scriptUpThroughReview(f); + await f.reply("graph retained-preflight", { + version: 1, + run: run.run, + owner: run.owner, + namespace: run.namespace, + plan: run.planId, + live_resources_verified: false, + runtime_phase: "stopped", + selection: "7".repeat(64), + }); + await f.reply("graph inspect", stoppedGraph); + await f.reply("graph restore-selection", {}); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + "Native restore selection changed; retained data was not adopted." + ); + expect((await f.calls()).at(-1)).toBe("graph restore-selection"); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-retained-images: a changed active image selection is one closed restart line", + async () => { + const f = await fixture(); + await savedRun(f); + await f.reply("runtime check-project-share", { + source_admitted: true, + pool_initialized: true, + }); + await f.reply("runtime status", { network: "internet" }); + await f.reply("runtime probe", { admitted: true }); + await f.reply("graph inspect", {}); + const result = await cli({ fixture: f, args: ["restart"], env: SHARED }); + expectOneClosedLine( + result, + "Native active image selection changed; the current graph was not stopped." + ); + expect(await f.calls()).toEqual([ + "runtime check-project-share", + "runtime status", + "runtime probe", + "graph inspect", + ]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-network: an invalid allowed-host selection is one coded line before any request", + async () => { + const f = await fixture(); + const result = await cli({ + fixture: f, + args: ["up"], + env: { ...SHARED, HACK_NATIVE_ALLOW_HOSTS: "localhost" }, + }); + expectOneClosedLine( + result, + "E_CONFIG_INVALID: HACK_NATIVE_ALLOW_HOSTS requires at most 32 unique canonical public DNS names; wildcards, IP addresses, local names and empty entries are refused. Values omitted." + ); + expect(await f.calls()).toEqual([]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-process: a failed serve with unconfirmed cleanup is one closed line", + async () => { + const f = await fixture(); + await scriptUpThroughReview(f); + await f.reply("graph dependency-plan", { + dependency_plan_id: "1".repeat(64), + }); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + "Native foreground exit cleanup is unconfirmed; runtime and bridge state may be retained. Any published run mapping is retained; inspect owned state before retrying. Startup diagnostic: Native graph startup was interrupted or failed (runtime_not_running). Native exit diagnostic: runtime_not_running." + ); + expect((await f.calls()).slice(-2)).toEqual([ + "graph serve", + "graph inspect", + ]); + }, + SPAWN_TIMEOUT_MS +); + +/** A project whose only service names a registry image the candidate refuses. */ +const REGISTRY_COMPOSE = JSON.stringify({ + services: { web: { image: "ghcr.io/example/app:1" } }, +}); +const IMAGE_REFUSAL = + "Native project image reference refused before runtime startup: use a public Docker Hub reference (shorthand such as alpine:3.21 is accepted) or a sha256 image ID; no VM, provider or image acquisition was started."; + +test( + "native-project-image-admission: a refused image reference is one coded line", + async () => { + const f = await fixture(REGISTRY_COMPOSE); + await scriptUpThroughReview(f); + await f.fail("runtime admit-image", { code: "registry_image" }); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + `E_NATIVE_PROJECT_UNSUPPORTED: ${IMAGE_REFUSAL}` + ); + expect(result.output).not.toContain("ghcr.io"); + expect((await f.calls()).at(-1)).toBe("runtime admit-image"); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-project-image-admission: a malformed admission reply is one closed integrity line", + async () => { + const f = await fixture(REGISTRY_COMPOSE); + await scriptUpThroughReview(f); + await f.reply("runtime admit-image", {}); + const result = await cli({ fixture: f, args: ["up"], env: SHARED }); + expectOneClosedLine( + result, + "Native runtime returned an invalid image admission response; no VM, provider or image acquisition was started." + ); + expect(result.output).not.toContain("ghcr.io"); + expect((await f.calls()).at(-1)).toBe("runtime admit-image"); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-prepared-base: an invalid prepared-base mode is one coded line before any request", + async () => { + const f = await fixture(); + const result = await cli({ + fixture: f, + args: ["up"], + env: { ...SHARED, HACK_NATIVE_PREPARED_BASE: "bogus" }, + }); + expectOneClosedLine( + result, + "E_CONFIG_INVALID: HACK_NATIVE_PREPARED_BASE must be prefer, require or off." + ); + expect(await f.calls()).toEqual([]); + }, + SPAWN_TIMEOUT_MS +); + +test( + "native-workspace-cache: an invalid cache layout is one coded line", + async () => { + const f = await fixture(); + await scriptUpThroughReview(f); + const adaptation = join(f.root, "adaptation.json"); + await writeFile( + adaptation, + JSON.stringify({ version: 1, workspaceCache: { bogus: 1 } }), + { mode: 0o600 } + ); + const result = await cli({ + fixture: f, + args: ["up"], + env: { ...SHARED, HACK_NATIVE_ADAPTATION: adaptation }, + }); + expectOneClosedLine( + result, + "E_CONFIG_INVALID: Native workspace cache layout conflicts with declared mounts or is invalid; values omitted." + ); + expect(await f.calls()).toEqual(["runtime check-project-share"]); + }, + SPAWN_TIMEOUT_MS +); diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 2e5fcf78..3b949378 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -6,8 +6,14 @@ import { invokeNativeRuntime, isNativeRuntimeNotRunning, NativeRuntimeRequestError, + nativeCodesReportRuntimeNotRunning, resolveNativeRuntimeSelection, } from "../src/backends/native-runtime-client.ts"; +import { ClosedCliError } from "../src/lib/cli-result.ts"; +import { + expectClosedRefusal, + expectCodedRefusal, +} from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -32,6 +38,7 @@ if (process.argv.includes("one-off-unsafe")) { console.error(JSON.stringify({cod if (process.argv.includes("owner-fail")) { const {appendFileSync}=await import("node:fs"); appendFileSync("attempts","attempt"); console.error(JSON.stringify({code:"graph_owner_recovery",cause_code:"graph_relay_identity",message:"synthetic-secret-diagnostic"})); process.exit(23); } if (process.argv.includes("owner-unsafe")) { console.error(JSON.stringify({code:"graph_owner_recovery",cause_code:"synthetic-secret-diagnostic",message:"synthetic-secret-diagnostic"})); process.exit(23); } if (process.argv.includes("fail")) { console.error("synthetic-secret-diagnostic"); process.exit(23); } +if (process.argv.includes("garbage")) { process.stdout.write("not-json"); process.exit(0); } if (process.argv.includes("overflow")) { process.stdout.write("x".repeat(17 * 1024 * 1024)); } else { const input = await Bun.stdin.text(); @@ -55,6 +62,17 @@ test("native selection is explicit and requires an isolated absolute home", () = HACK_NATIVE_HOME: "/tmp/home", }) ).toThrow(); + // The refusal is closed: the CLI prints this one line without a stack. + let refusal: unknown; + try { + resolveNativeRuntimeSelection({ HACK_RUNTIME_BACKEND: "native" }); + } catch (error: unknown) { + refusal = error; + } + expect(refusal).toBeInstanceOf(ClosedCliError); + expect(String(refusal)).toContain( + "Native runtime requires absolute HACK_NATIVE_BINARY and HACK_NATIVE_HOME paths." + ); expect( resolveNativeRuntimeSelection({ HACK_RUNTIME_BACKEND: "native", @@ -527,17 +545,40 @@ await Bun.sleep(60_000); test("private-input and response bounds reject instead of truncating", async () => { const runtime = await fixture(); - await expect( + // A managed environment or a selected timeout can exceed the bounds: a + // coded configuration refusal, not an unexpected exception. + await expectCodedRefusal( invokeNativeRuntime({ runtime, cwd: runtime.home, args: [], privateInput: new Uint8Array(256 * 1024 + 1), - }) - ).rejects.toThrow("input or time budget"); - await expect( - invokeNativeRuntime({ runtime, cwd: runtime.home, args: ["overflow"] }) - ).rejects.toThrow("output budget"); + }), + "E_CONFIG_INVALID", + "input or time budget" + ); + await expectCodedRefusal( + invokeNativeRuntime({ + runtime, + cwd: runtime.home, + args: [], + timeoutMs: 600_001, + }), + "E_CONFIG_INVALID", + "input or time budget" + ); + await expectClosedRefusal( + invokeNativeRuntime({ runtime, cwd: runtime.home, args: ["overflow"] }), + "output budget" + ); +}); + +test("an unparseable control response is a closed refusal", async () => { + const runtime = await fixture(); + await expectClosedRefusal( + invokeNativeRuntime({ runtime, cwd: runtime.home, args: ["garbage"] }), + "Native runtime returned an invalid control response." + ); }); test("structured failures expose only a bounded code, never their message", async () => { @@ -586,6 +627,28 @@ test.each([ ); }); +test("a carried code pair reports a direct or foreground-wrapped stopped runtime only", () => { + expect( + nativeCodesReportRuntimeNotRunning({ nativeCode: "runtime_not_running" }) + ).toBe(true); + expect( + nativeCodesReportRuntimeNotRunning({ + nativeCode: "native_graph_foreground", + nativeCauseCode: "runtime_not_running", + }) + ).toBe(true); + for (const codes of [ + { nativeCode: "native_graph_foreground" }, + { + nativeCode: "graph_owner_recovery", + nativeCauseCode: "runtime_not_running", + }, + {}, + ]) { + expect(nativeCodesReportRuntimeNotRunning(codes)).toBe(false); + } +}); + test("one-off failures expose only a validated owner cause code", async () => { const runtime = await fixture(); await expect( @@ -807,8 +870,13 @@ test("exec accepts matching nonzero completion only through its explicit transpo args: ["graph", "cleanup", "--json"], }) ).rejects.toThrow("budget"); - } else { + } else if (variant === "native-error") { await expect(invokeNativeRuntime(request)).rejects.toThrow(); + } else { + await expectClosedRefusal( + invokeNativeRuntime(request), + "Native exec returned an invalid or inconsistent completion response; command effects may have occurred." + ); } } }); diff --git a/tests/native-runtime-metadata.test.ts b/tests/native-runtime-metadata.test.ts index 3022b450..8504538f 100644 --- a/tests/native-runtime-metadata.test.ts +++ b/tests/native-runtime-metadata.test.ts @@ -19,6 +19,7 @@ import { buildRuntimeHostMetadataOverride, type RuntimeHostMetadata, } from "../src/lib/runtime-host-metadata.ts"; +import { expectCodedThrow } from "./helpers/closed-refusal.ts"; const roots: string[] = []; afterEach(async () => { @@ -305,20 +306,25 @@ test("reviewed routes must match the advertised hostnames for active services", routes, }); for (const changed of [["a.hack"], ["a.hack", "c.hack"], [1], undefined]) { - expect(() => - verifyNativeRuntimeMetadataRoutes({ plan: plan(changed), routes }) - ).toThrow("Native runtime metadata"); + expectCodedThrow( + () => verifyNativeRuntimeMetadataRoutes({ plan: plan(changed), routes }), + "E_NATIVE_PROJECT_UNSUPPORTED", + "Native runtime metadata" + ); } - expect(() => - verifyNativeRuntimeMetadataRoutes({ - plan: { - services: { - worker: { active: true, routing: { hostnames: ["w.hack"] } }, + expectCodedThrow( + () => + verifyNativeRuntimeMetadataRoutes({ + plan: { + services: { + worker: { active: true, routing: { hostnames: ["w.hack"] } }, + }, }, - }, - routes, - }) - ).toThrow("Native runtime metadata"); + routes, + }), + "E_NATIVE_PROJECT_UNSUPPORTED", + "Native runtime metadata" + ); }); test("retained graphs reproduce exactly the metadata selection they were admitted with", () => { diff --git a/tests/native-runtime-not-running-cli.test.ts b/tests/native-runtime-not-running-cli.test.ts index 6602bf9b..16102489 100644 --- a/tests/native-runtime-not-running-cli.test.ts +++ b/tests/native-runtime-not-running-cli.test.ts @@ -98,15 +98,17 @@ async function cli(opts: { readonly args: readonly string[]; readonly env?: Record; }) { + // Project selection goes first: after a child command (`run web bun ...`) + // every token belongs to the child. const child = Bun.spawn( [ process.execPath, entrypoint, - ...opts.args, "--path", opts.fixture.project, "--branch", "alpha", + ...opts.args, ], { cwd: opts.fixture.root, @@ -216,3 +218,34 @@ test("HACK_DEBUG=1 adds the stack to a closed native refusal", async () => { expect(result.code).toBe(1); expect(result.stdout + result.stderr).toMatch(STACK_FRAME); }); + +test("a refused native run prints the refusal and one hint, not the help or Hack's version", async () => { + const f = await fixture({ nativeCode: "runtime_not_running" }); + const result = await cli({ + fixture: f, + args: ["run", "--profile", "toolchain", "web", "bun", "--version"], + }); + const output = result.stdout + result.stderr; + expect(result.code).toBe(1); + expect(output).toContain( + "Native run uses the started project's environment and profiles; restart with the requested selections first." + ); + expect(output).toContain("See `hack run --help` for usage."); + expect(output).not.toContain("hack [options]"); + expect(result.stdout).not.toMatch(/^hack v\d/m); + expect(output).not.toMatch(STACK_FRAME); + expect(await nativeCalls(f.root)).toEqual([]); +}, 30_000); + +test("a child --version reaches the native one-off instead of printing Hack's version", async () => { + const f = await fixture({ nativeCode: "runtime_not_running" }); + const result = await cli({ + fixture: f, + args: ["run", "web", "bun", "--version"], + }); + expect(result.code).not.toBe(0); + expect(result.stdout).not.toMatch(/^hack v\d/m); + expect(result.stdout + result.stderr).not.toMatch(STACK_FRAME); + // The one-off reached the runtime (which reports itself stopped). + expect((await nativeCalls(f.root)).length).toBeGreaterThan(0); +}, 30_000); diff --git a/tests/native-runtime-preflight.test.ts b/tests/native-runtime-preflight.test.ts new file mode 100644 index 00000000..38f41ad0 --- /dev/null +++ b/tests/native-runtime-preflight.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { + isNativeRuntimeNotRunning, + NativeRuntimeRequestError, +} from "../src/backends/native-runtime-client.ts"; +import { + nativeRuntimeStatusStopped, + refuseStoppedNativeRuntime, +} from "../src/backends/native-runtime-preflight.ts"; + +const runtime = { binary: "/fixture/native", home: "/fixture/home" }; + +async function preflight(reply: () => Promise) { + const requests: (readonly string[])[] = []; + const result = await refuseStoppedNativeRuntime({ + runtime, + cwd: "/fixture/project", + timeoutMs: 5000, + invoke: (request) => { + requests.push(request.args); + return reply(); + }, + }).catch((error: unknown) => error); + return { result, requests }; +} + +test.each([ + { phase: "stopped", process_alive: null }, + // Any non-running phase refuses on its own, without a dead-process signal. + { phase: "initializing", process_alive: null }, + { phase: "uninitialized", process_alive: false }, + { phase: "process-exited", process_alive: false }, + { phase: "running", process_alive: false }, +])("a status reply %j refuses as runtime_not_running", async (reply) => { + expect(nativeRuntimeStatusStopped(reply)).toBe(true); + const { result, requests } = await preflight(async () => reply); + expect(isNativeRuntimeNotRunning(result)).toBe(true); + expect(requests).toEqual([["runtime", "status", "--json"]]); +}); + +test("a running runtime passes", async () => { + const { result } = await preflight(async () => ({ + phase: "running", + process_alive: true, + })); + expect(result).toBeUndefined(); +}); + +test("an inconclusive status does not refuse the start", async () => { + for (const reply of [ + async () => ({}), + async () => null, + async () => { + throw new NativeRuntimeRequestError({ + message: "Native runtime request failed (engine_protocol).", + nativeCode: "engine_protocol", + }); + }, + async () => { + throw new Error("spawn failed"); + }, + ]) { + expect((await preflight(reply)).result).toBeUndefined(); + } +}); + +test("a runtime_not_running refusal from the status request is kept", async () => { + const refusal = new NativeRuntimeRequestError({ + message: "Native runtime request failed (runtime_not_running).", + nativeCode: "runtime_not_running", + }); + const { result } = await preflight(async () => { + throw refusal; + }); + expect(result).toBe(refusal); +}); diff --git a/tests/native-selection-refusals.test.ts b/tests/native-selection-refusals.test.ts new file mode 100644 index 00000000..c688d7e2 --- /dev/null +++ b/tests/native-selection-refusals.test.ts @@ -0,0 +1,58 @@ +import { afterEach, test } from "bun:test"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { addNativeHostAliases } from "../src/backends/native-project-host-aliases.ts"; +import { readNativeSelection } from "../src/backends/native-project-selection.ts"; +import { + expectCodedRefusal, + expectCodedThrow, +} from "./helpers/closed-refusal.ts"; + +/** + * Private native selection files and host-alias selections are user + * configuration: a malformed one is a coded `E_CONFIG_INVALID` refusal, which + * the CLI prints as one line without a stack. + */ + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +test("a malformed or linked selection file is a coded configuration refusal", async () => { + const root = await mkdtemp(join(tmpdir(), "native-selection-")); + roots.push(root); + const malformed = join(root, "malformed.json"); + await writeFile(malformed, "{not json", { mode: 0o600 }); + const linked = join(root, "linked.json"); + await symlink(malformed, linked); + for (const path of [malformed, linked, "relative.json"]) { + await expectCodedRefusal( + readNativeSelection(path), + "E_CONFIG_INVALID", + "Native selection requires a bounded, unchanged regular JSON file; values omitted." + ); + } +}); + +test("a conflicting host alias selection is a coded configuration refusal", () => { + for (const selection of [ + [], + { web: [] }, + { web: ["not a host"] }, + { missing: ["api.example.com"] }, + ]) { + expectCodedThrow( + () => + addNativeHostAliases({ + services: { web: { image: "busybox" } }, + selection, + }), + "E_CONFIG_INVALID", + "Native host alias selection conflicts with declared configuration; values omitted." + ); + } +});