From 2a99d5b7337d4258d7894b7c4ec937f8b506d6bd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 13:19:40 -0400 Subject: [PATCH 1/5] fix(cli): pass child flags through and print closed native refusals Flags after a child command now belong to the child: `hack run web bun --version` no longer prints Hack's version. Positionals marked `passthrough` (run, exec, host exec, env exec, session exec, x) end Hack's option parsing at the child's first word; a leading `--` stays optional and a later `--` reaches the child unchanged. Known native refusals print one closed line instead of a stack: the configuration refusal is E_NATIVE_PROJECT_UNSUPPORTED, the shared-source grant is E_PERMISSION, and the remaining value-free native start, run, exec, down and restart refusals are ClosedCliError. Usage refusals thrown by a handler print the message and a one-line help pointer instead of the full help. Native up --detach/--json/--target/services refuse by name with the supported foreground form. Authored native up checks runtime status before reserving its startup intent, so a stopped runtime refuses with E_DOCKER_UNAVAILABLE and leaves ps at not_started instead of pending. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 6 + docs/guides/native-candidate.md | 11 +- docs/reference/cli.md | 24 ++- docs/reference/native-authored-commands.md | 22 +- src/backends/native-authored-project-start.ts | 11 + src/backends/native-https-owner.ts | 3 +- src/backends/native-https-startup-failure.ts | 3 +- .../native-project-dependency-refresh.ts | 5 +- src/backends/native-project-down.ts | 9 +- src/backends/native-project-exec.ts | 7 +- .../native-project-interrupted-cleanup.ts | 5 +- src/backends/native-project-observe.ts | 2 +- src/backends/native-project-one-off.ts | 7 +- src/backends/native-project-restart.ts | 11 +- src/backends/native-project-start.ts | 79 ++++--- src/backends/native-runtime-client.ts | 2 +- src/backends/native-runtime-preflight.ts | 64 ++++++ src/cli/command.ts | 125 ++++++++++- src/cli/help.ts | 49 ++++- src/cli/run.ts | 23 +- src/commands/env.ts | 8 +- src/commands/project.ts | 51 +++-- src/commands/session.ts | 1 + src/commands/x.ts | 4 +- src/lib/native-authored-command.ts | 56 ++++- src/lib/native-up-refusals.ts | 41 ++++ tests/cli-closed-error-output.test.ts | 107 ++++++++++ tests/cli-passthrough-argv.test.ts | 201 ++++++++++++++++++ tests/native-authored-command.test.ts | 56 +++++ tests/native-authored-project-start.test.ts | 43 ++++ tests/native-project-observe.test.ts | 7 +- tests/native-project-start.test.ts | 46 +++- tests/native-runtime-not-running-cli.test.ts | 35 ++- tests/native-runtime-preflight.test.ts | 74 +++++++ 34 files changed, 1091 insertions(+), 107 deletions(-) create mode 100644 src/backends/native-runtime-preflight.ts create mode 100644 src/lib/native-up-refusals.ts create mode 100644 tests/cli-passthrough-argv.test.ts create mode 100644 tests/native-runtime-preflight.test.ts diff --git a/docs/cli.md b/docs/cli.md index 562f75097..06c167b63 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -187,6 +187,12 @@ 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`, `session exec` and `x`, 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. 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. + 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 b5f309b4c..c4509e0b6 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -636,9 +636,16 @@ 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; any other unsupported configuration +refuses with `E_NATIVE_PROJECT_UNSUPPORTED` 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 dd1f96c8c..eecdfd4e6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -234,9 +234,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 | @@ -266,9 +268,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 | @@ -815,9 +819,11 @@ Send a command to a running workspace ### Usage ```bash -hack session exec [options] +hack session exec [options] ``` +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. + 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 @@ -2423,9 +2429,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 @@ -2585,9 +2593,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 @@ -2647,9 +2657,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 e27d7f184..72e325321 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -96,6 +96,20 @@ 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. +`--detach` refuses with `E_NATIVE_PROJECT_UNSUPPORTED` and says that native `up` runs in +the foreground only: 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 @@ -107,7 +121,10 @@ 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. 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 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 @@ -241,7 +258,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/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 6133d9cd9..68b621015 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -66,10 +66,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>; type Outcome = "not-started" | "removed" | "retained"; const STAGES = [ @@ -1019,6 +1022,14 @@ 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, + }); attempt = { source, start: await admission.reserve({ review }) }; outcome = "retained"; stage = "runtime"; diff --git a/src/backends/native-https-owner.ts b/src/backends/native-https-owner.ts index 753bdf0ed..37aa8ad71 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-startup-failure.ts b/src/backends/native-https-startup-failure.ts index a48edfe0c..dd61ded9b 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-project-dependency-refresh.ts b/src/backends/native-project-dependency-refresh.ts index 62a914ba9..b63d2522f 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 ecbf08378..5b8f4083a 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 efa970419..b9933547a 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-interrupted-cleanup.ts b/src/backends/native-project-interrupted-cleanup.ts index 0d69cecc3..c12d1485d 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-observe.ts b/src/backends/native-project-observe.ts index 60aee6564..aaa1d91b3 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 e7780a2ac..8373b094a 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-restart.ts b/src/backends/native-project-restart.ts index fd981d063..0a408e5d0 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-start.ts b/src/backends/native-project-start.ts index 177abc1c1..1af02b5e7 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, @@ -106,9 +107,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( @@ -126,9 +129,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, @@ -164,7 +169,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." ); } @@ -269,15 +274,27 @@ 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, + }); +} + +/** 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( @@ -306,6 +323,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; @@ -317,7 +337,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) { @@ -456,7 +476,7 @@ function redirectHostname( reviewed.path !== route.path || target.pathname !== route.path ) { - throw new Error( + throw new ClosedCliError( "Native HTTPS verification failed (REDIRECT_TARGET_REFUSED); peer values omitted." ); } @@ -491,7 +511,7 @@ export async function verifyHttpsRoutes( let hostname = route.hostname; while (true) { if (visited.has(hostname)) { - throw new Error( + throw new ClosedCliError( "Native HTTPS verification failed (REDIRECT_CYCLE); peer values omitted." ); } @@ -504,7 +524,7 @@ export async function verifyHttpsRoutes( break; } if (!(HTTPS_REDIRECTS.has(response.statusCode) && response.location)) { - throw new Error( + throw new ClosedCliError( `Native HTTPS verification failed (HTTP_STATUS_${response.statusCode}); peer values omitted.` ); } @@ -571,7 +591,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}.` : ""}` ); } @@ -640,7 +660,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." ); } @@ -664,7 +684,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." ); } @@ -706,7 +726,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, @@ -729,7 +751,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." ); } @@ -770,7 +792,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." ); } @@ -835,7 +857,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." ); } @@ -878,9 +900,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); @@ -942,7 +965,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." ); } @@ -972,7 +995,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." ); } @@ -1255,7 +1278,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." ); } @@ -1299,7 +1322,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 765b5d77e..780b4dfb3 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -79,7 +79,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." ); } diff --git a/src/backends/native-runtime-preflight.ts b/src/backends/native-runtime-preflight.ts new file mode 100644 index 000000000..3ec0fbfc6 --- /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/cli/command.ts b/src/cli/command.ts index d12b833f1..db9300487 100644 --- a/src/cli/command.ts +++ b/src/cli/command.ts @@ -34,6 +34,15 @@ 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. + */ + readonly passthrough?: boolean; } export interface CliContext { @@ -224,7 +233,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 +316,111 @@ 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; +} + +/** 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); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 986e35c63..8b66a3471 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,35 @@ 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 name = passthroughPositionalName(command); + if (!name) { + return; + } + return `Everything from \`<${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 cf2d4eaa2..1bad3b51c 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -32,6 +32,8 @@ function isTruthyEnv(value: string | undefined): boolean { export async function runCli(argv: readonly string[]): Promise { const jsonRequested = 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 +104,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 +120,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 +146,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 +169,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 +205,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/env.ts b/src/commands/env.ts index bcef1651d..570b02dc9 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 073ce8ee2..eace62851 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -214,6 +214,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, @@ -498,7 +499,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", @@ -4920,16 +4921,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, @@ -5005,9 +5003,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, @@ -5021,9 +5020,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.", + }); } }, }; @@ -5037,9 +5038,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({ @@ -5169,9 +5172,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 d300eac4c..4a743c4c5 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 4a3fa0446..e1a0032d5 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 0187256bb..c9e82a10b 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -29,6 +29,7 @@ import { resolveNativeAuthoredExecStaleEnvWarning } from "../backends/native-exe import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; import { isNativeRuntimeNotRunning, + NATIVE_RUNTIME_NOT_RUNNING, resolveNativeRuntimeSelection, } from "../backends/native-runtime-client.ts"; import { CliUsageError } from "../cli/command.ts"; @@ -44,14 +45,26 @@ import { type NativeEnvFingerprintSession, } from "./native-env-fingerprint-state.ts"; -function unsupported(): never { +function unsupported(hint?: string): never { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: - "Native authored execution requires whole-project foreground up or restart, owner-mediated down, ps, finite single-service logs --no-follow, noninteractive single-service exec or run, or explicit stored-generation down --recover on macOS. This request ran no input or runtime operation.", + message: `Native authored execution requires whole-project foreground up or restart, owner-mediated down, ps, finite single-service logs --no-follow, noninteractive single-service exec or run, or explicit stored-generation down --recover on macOS. This request ran no input or runtime operation.${hint ? ` ${hint}` : ""}`, }); } +/** The supported alternative to a detached native foreground start. */ +function foregroundDetachHint( + options: NativeComposeCommandOptions +): string | undefined { + if ( + !(options.detach && ["up", "restart"].includes(options.operation)) || + process.platform !== "darwin" + ) { + return; + } + return `Native \`hack ${options.operation}\` runs in the foreground only, so --detach is not supported: run it 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.`; +} + function assertLogsOptions(options: NativeComposeCommandOptions): void { if ( process.platform !== "darwin" || @@ -199,7 +212,7 @@ function assertForegroundOptions(options: NativeComposeCommandOptions): void { options.overlay !== undefined || options.services !== undefined)) ) { - unsupported(); + unsupported(foregroundDetachHint(options)); } } @@ -208,6 +221,9 @@ function startupFailure(error: unknown): number { if (error.canceled && error.outcome === "removed") { return 130; } + if (error.nativeCode === NATIVE_RUNTIME_NOT_RUNNING) { + throw stoppedRuntimeStartup(error); + } throw new HackCliError({ code: error.nativeCode === "native_graph_subset" @@ -229,6 +245,36 @@ 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. + */ +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.", + 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 ( @@ -296,7 +342,7 @@ const STATUS_NOTES: Readonly< > = { not_started: "Native project has not been started.", pending: - "Native project startup is pending; no current service status is confirmed.", + "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.", 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 000000000..0427481b8 --- /dev/null +++ b/src/lib/native-up-refusals.ts @@ -0,0 +1,41 @@ +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. + */ +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) { + refuse( + `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.` + ); + } + 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 07b0ed038..edd9e1d30 100644 --- a/tests/cli-closed-error-output.test.ts +++ b/tests/cli-closed-error-output.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { NativeHttpsStartupError } from "../src/backends/native-https-startup-failure.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 +106,106 @@ 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`); +}); diff --git a/tests/cli-passthrough-argv.test.ts b/tests/cli-passthrough-argv.test.ts new file mode 100644 index 000000000..f2db87339 --- /dev/null +++ b/tests/cli-passthrough-argv.test.ts @@ -0,0 +1,201 @@ +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, + parseCliArgv, + 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"] }, +] 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("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"]], +])( + "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( + "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/native-authored-command.test.ts b/tests/native-authored-command.test.ts index cbb484264..74ef8ef16 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -491,6 +491,62 @@ macTest( } ); +macTest.each([ + { + outcome: "not-started" as const, + text: "Native runtime is not running; nothing was started. Start the native runtime, then run this command again.", + }, + { + outcome: "retained" as const, + text: "so `hack ps` reports it as pending", + }, +])( + "a start refused by a stopped runtime ($outcome) reports the runtime, not incomplete startup", + async ({ outcome, text }) => { + const selected = await fixture(); + const value = await tryNativeAuthoredCommand({ + ...selected, + serve: () => + Promise.reject( + new NativeAuthoredProjectStartError({ + outcome, + canceled: false, + nativeCode: "runtime_not_running", + stage: "reservation", + }) + ), + }).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: "reservation", + nativeCode: "runtime_not_running", + }); + } +); + +macTest( + "a detached native up names the foreground-only contract and its alternative", + async () => { + const selected = await fixture(); + const value = await tryNativeAuthoredCommand({ + ...selected, + options: { ...selected.options, detach: true }, + }).catch((caught: unknown) => caught); + expect(value).toBeInstanceOf(HackCliError); + expect(value).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect(value).toHaveProperty( + "message", + expect.stringContaining( + "Native `hack up` runs in the foreground only, so --detach is not supported: run it without --detach in its own terminal" + ) + ); + } +); + 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 437adc5ec..42896df44 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -12,6 +12,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { nativeAuthoredProjectPs } from "../src/backends/native-authored-project-observe.ts"; import { loadNativeAuthoredProjectRun, stopNativeAuthoredProject, @@ -205,6 +206,12 @@ 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; readonly cleanup?: | "removed" | "live" @@ -401,6 +408,7 @@ 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)} const journal=${JSON.stringify(journal)}; const root=${JSON.stringify(join(projectDir, ".internal", "native-authored-runs"))}; if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ @@ -578,6 +586,41 @@ 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( "native frontend holds admission through exact ready publication and Removed retirement", async () => { diff --git a/tests/native-project-observe.test.ts b/tests/native-project-observe.test.ts index ea852312c..df71d9e98 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-start.test.ts b/tests/native-project-start.test.ts index 4a8721f2d..09955f8c6 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, @@ -481,20 +485,52 @@ 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("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-runtime-not-running-cli.test.ts b/tests/native-runtime-not-running-cli.test.ts index 6602bf9bd..161024896 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 000000000..a09191f7a --- /dev/null +++ b/tests/native-runtime-preflight.test.ts @@ -0,0 +1,74 @@ +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 }, + { 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); +}); From c5b42028955ebb85f5adbaa331a78a87d7966faf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 14:12:08 -0400 Subject: [PATCH 2/5] fix(cli): close remaining child-flag and native refusal gaps A child `--json` no longer selects Hack's JSON error envelope: only argv before the passthrough child command and before the first `--` counts. A cancel or deadline during the authored start's runtime status check now refuses before the startup intent is reserved, so ps stays not_started. A start that fails because authored foreground control reports a stopped runtime wrapped in native_graph_foreground (#331) now reports E_DOCKER_UNAVAILABLE like the direct runtime_not_running code; the serve exit diagnostic carries the bounded cause code to the start error. Native project input and route refusals are E_NATIVE_PROJECT_UNSUPPORTED; branch, adaptation, AWS and fresh-exec environment refusals are E_CONFIG_INVALID; source admission and restart preflight refusals are closed. Each prints one line without a stack. `session exec` keeps its single quoted command: an unquoted command now refuses with a hint to quote it, and its help and docs say so. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 16 ++-- docs/guides/native-candidate.md | 7 +- docs/reference/cli.md | 2 +- src/backends/native-authored-project-start.ts | 34 +++++++- src/backends/native-aws-environment.ts | 9 +- src/backends/native-exec-environment.ts | 17 ++-- src/backends/native-project-adaptation.ts | 9 +- src/backends/native-project-branch.ts | 9 +- src/backends/native-project-input.ts | 9 +- src/backends/native-project-process.ts | 12 ++- .../native-project-restart-preflight.ts | 31 +++---- src/backends/native-project-routing.ts | 9 +- .../native-project-source-preflight.ts | 3 +- src/backends/native-runtime-client.ts | 35 +++++++- src/cli/command.ts | 31 ++++++- src/cli/help.ts | 9 +- src/cli/run.ts | 5 +- src/lib/native-authored-command.ts | 3 +- tests/cli-help.test.ts | 15 ++++ tests/cli-passthrough-argv.test.ts | 83 +++++++++++++++++++ tests/native-authored-command.test.ts | 23 +++-- tests/native-authored-project-start.test.ts | 77 ++++++++++++++++- tests/native-aws-environment.test.ts | 5 +- tests/native-exec-environment.test.ts | 10 ++- tests/native-project-adaptation.test.ts | 13 ++- tests/native-project-branch.test.ts | 7 +- tests/native-project-input.test.ts | 14 ++-- tests/native-project-restart.test.ts | 12 +++ tests/native-project-routing.test.ts | 13 +++ tests/native-project-source-preflight.test.ts | 16 ++-- tests/native-project-start.test.ts | 13 +++ tests/native-runtime-client.test.ts | 35 ++++++++ 32 files changed, 498 insertions(+), 88 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 06c167b63..cbccd2fb7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -187,11 +187,17 @@ 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`, `session exec` and `x`, 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. 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. +For `run`, `exec`, `host exec`, `env exec` and `x`, 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 c4509e0b6..800b3b11d 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -644,8 +644,11 @@ services or external networks. `--detach`, `--json`, `--target` and service name 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; any other unsupported configuration -refuses with `E_NATIVE_PROJECT_UNSUPPORTED` and drops nothing. Existing +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`. 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 eecdfd4e6..b3ea3fd59 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -822,7 +822,7 @@ Send a command to a running workspace hack session exec [options] ``` -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. +`` 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. diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 68b621015..284c197f7 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -116,6 +116,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; @@ -123,6 +128,7 @@ export class NativeAuthoredProjectStartError extends Error { readonly outcome: Outcome; readonly canceled: boolean; readonly nativeCode?: string; + readonly nativeCauseCode?: string; readonly stage?: Stage; readonly compilerCode?: string; }) { @@ -149,6 +155,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; } @@ -538,7 +548,9 @@ async function runPreparedLifecycle(ctx: { readonly published: () => void; readonly removed: () => void; readonly hookRetired: () => void; - readonly nativeCode: (code: string | undefined) => void; + readonly nativeCode: ( + diagnostic: Pick + ) => void; }): Promise { const review = ctx.review; let downBefore = false; @@ -600,7 +612,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); }, @@ -698,10 +710,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(); @@ -715,7 +729,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 && @@ -725,11 +742,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), }); @@ -860,6 +879,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; @@ -1030,6 +1050,10 @@ export async function serveNativeAuthoredProject( 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"; @@ -1080,7 +1104,8 @@ export async function serveNativeAuthoredProject( hookOwner = undefined; }, nativeCode: (value) => { - nativeCode = value; + nativeCode = value.nativeCode; + nativeCauseCode = value.nativeCauseCode; }, }); return { ok: true as const, code }; @@ -1102,6 +1127,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 90ec577b8..3f0804d3c 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 bb294e2e8..ee1f20a62 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-project-adaptation.ts b/src/backends/native-project-adaptation.ts index a115f74fc..b1bcc6597 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 fc9cc15f8..ce387d390 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-input.ts b/src/backends/native-project-input.ts index 63be14fcf..ab83234d9 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-process.ts b/src/backends/native-project-process.ts index 89ea2d65b..2df300e59 100644 --- a/src/backends/native-project-process.ts +++ b/src/backends/native-project-process.ts @@ -12,7 +12,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 +24,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 = { @@ -321,7 +323,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; @@ -445,13 +448,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. diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index cbb5a74d5..cd7ac3ce4 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-routing.ts b/src/backends/native-project-routing.ts index 780dd2a8c..bb78fb0e9 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-source-preflight.ts b/src/backends/native-project-source-preflight.ts index 5b1b1b2df..b5a61bb0e 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-runtime-client.ts b/src/backends/native-runtime-client.ts index 780b4dfb3..5ad29c87b 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -54,9 +54,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) ); } @@ -600,6 +615,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/cli/command.ts b/src/cli/command.ts index db9300487..1dc48c438 100644 --- a/src/cli/command.ts +++ b/src/cli/command.ts @@ -40,7 +40,8 @@ export interface PositionalSpec { * `--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. + * 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; } @@ -368,6 +369,25 @@ export function findPassthroughStart( 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(); @@ -572,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 8b66a3471..b497c5656 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -419,11 +419,14 @@ function passthroughPositionalName( /** One line explaining where a child command's own argv begins. */ function renderPassthroughNote(command: AnyCommandSpec): string | undefined { - const name = passthroughPositionalName(command); - if (!name) { + const spec = command.positionals.find((p) => p.passthrough === true); + if (!spec) { return; } - return `Everything from \`<${name}>\` on is passed to the command unchanged, including flags such as \`--version\` or \`--help\`. Put Hack options before it; a leading \`--\` is optional.`; + 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( diff --git a/src/cli/run.ts b/src/cli/run.ts index 1bad3b51c..375d83770 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,9 @@ 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 { diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index c9e82a10b..31325b741 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -30,6 +30,7 @@ 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"; @@ -221,7 +222,7 @@ function startupFailure(error: unknown): number { if (error.canceled && error.outcome === "removed") { return 130; } - if (error.nativeCode === NATIVE_RUNTIME_NOT_RUNNING) { + if (nativeCodesReportRuntimeNotRunning(error)) { throw stoppedRuntimeStartup(error); } throw new HackCliError({ diff --git a/tests/cli-help.test.ts b/tests/cli-help.test.ts index ddf8d7e46..7b3c61212 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 index f2db87339..98aa5347f 100644 --- a/tests/cli-passthrough-argv.test.ts +++ b/tests/cli-passthrough-argv.test.ts @@ -4,7 +4,9 @@ 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"; @@ -110,6 +112,46 @@ test("session exec never gives a flag after the workspace command to Hack", () = 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); @@ -190,6 +232,47 @@ test.each([ 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 () => { diff --git a/tests/native-authored-command.test.ts b/tests/native-authored-command.test.ts index 74ef8ef16..46ce7a186 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -494,15 +494,27 @@ 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", }, ])( - "a start refused by a stopped runtime ($outcome) reports the runtime, not incomplete startup", - async ({ outcome, text }) => { + "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, @@ -511,8 +523,9 @@ macTest.each([ new NativeAuthoredProjectStartError({ outcome, canceled: false, - nativeCode: "runtime_not_running", - stage: "reservation", + nativeCode, + nativeCauseCode, + stage, }) ), }).catch((caught: unknown) => caught); @@ -522,7 +535,7 @@ macTest.each([ expect(value).toHaveProperty("detail", { outcome, canceled: false, - stage: "reservation", + stage, nativeCode: "runtime_not_running", }); } diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 42896df44..1ca15edaa 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -199,6 +199,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; readonly foreignPlan?: boolean; readonly foreignNamespace?: boolean; readonly branch?: string | null; @@ -212,6 +218,8 @@ type FixtureOptions = { * inconclusive. */ readonly runtimeStopped?: boolean; + /** `runtime status --json` hangs, so a caller can cancel during the check. */ + readonly runtimeStatusHangs?: boolean; readonly cleanup?: | "removed" | "live" @@ -409,6 +417,7 @@ 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)} const journal=${JSON.stringify(journal)}; const root=${JSON.stringify(join(projectDir, ".internal", "native-authored-runs"))}; if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ @@ -453,6 +462,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:'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); @@ -477,7 +487,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ } await finish(); }else{ - if(action==='inspect'&&${options.failBeforeReceipt === true}){console.error(JSON.stringify({code:'native_graph_receipt',message:${JSON.stringify(CANARY)}}));process.exit(2)} + if(action==='inspect'&&${options.failBeforeReceipt === true || options.stoppedDuringServe === true}){console.error(JSON.stringify({code:'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'}}; @@ -545,6 +555,24 @@ macTest( } ); +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", @@ -621,6 +649,53 @@ macTest( } ); +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 a19ffc405..dc2a04250 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 aa709ad89..a51cf1188 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-project-adaptation.test.ts b/tests/native-project-adaptation.test.ts index f864b0cd5..c8edb9392 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 c0fa31df7..9a75a7786 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-input.test.ts b/tests/native-project-input.test.ts index 3714e8ce5..ed7598f98 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-restart.test.ts b/tests/native-project-restart.test.ts index 4bf9424b0..a3650f72c 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -27,6 +27,7 @@ import { saveNativeRestartIntent, withNativeRestartLock, } from "../src/backends/native-project-run.ts"; +import { ClosedCliError } from "../src/lib/cli-result.ts"; const run = { run: "a".repeat(32), @@ -296,6 +297,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 () => { diff --git a/tests/native-project-routing.test.ts b/tests/native-project-routing.test.ts index 5fc2d98e9..24088cbff 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-source-preflight.test.ts b/tests/native-project-source-preflight.test.ts index 064fb695a..517336a8e 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 09955f8c6..3b4b9f0ac 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -508,6 +508,19 @@ test.each([ }); }); +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([]); +}); + test("source opt-in and occupied mapping refuse before lifecycle or runtime effects", async () => { const { opts, events } = await fixture(); const refusal = await startNativeProject({ diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 2e5fcf788..8374f6c66 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -6,8 +6,10 @@ import { invokeNativeRuntime, isNativeRuntimeNotRunning, NativeRuntimeRequestError, + nativeCodesReportRuntimeNotRunning, resolveNativeRuntimeSelection, } from "../src/backends/native-runtime-client.ts"; +import { ClosedCliError } from "../src/lib/cli-result.ts"; const roots: string[] = []; afterEach(async () => { @@ -55,6 +57,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", @@ -586,6 +599,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( From 6ec1ae2d236d1d2183ac597008d60ec89f68d8dd Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 20 Oct 2026 09:12:31 -0400 Subject: [PATCH 3/5] fix(cli): close remaining native refusals and dispatch run child argv Complete the native refusal audit on the image-project up, restart, run, exec and ps paths. Configuration refusals carry a stable code (E_CONFIG_INVALID, E_NATIVE_PROJECT_UNSUPPORTED or E_PERMISSION). Integrity, ownership and change refusals are closed, so the CLI prints one line without a stack. Start-time receipt, ownership, dependency-plan and HTTPS verification failures no longer reuse the configuration refusal. Mark the dispatch run command as passthrough, so a child --version reaches the child. Give authored up/restart --detach the image project's one-line E_USAGE refusal. Add a CLI spawn test with a stub runtime for representative refusal sites, class and code assertions for the round-1 conversions, a non-running phase preflight case, and a failedAdmission wrapped-cause test. Co-Authored-By: Claude Opus 5.5 --- docs/cli.md | 3 +- docs/guides/native-candidate.md | 8 +- docs/reference/native-authored-commands.md | 7 +- src/backends/native-https-port.ts | 35 +- src/backends/native-project-cache.ts | 13 +- .../native-project-cleanup-command.ts | 4 +- src/backends/native-project-dependencies.ts | 18 +- src/backends/native-project-finalization.ts | 3 +- src/backends/native-project-host-aliases.ts | 9 +- src/backends/native-project-https.ts | 5 +- src/backends/native-project-network.ts | 9 +- src/backends/native-project-process.ts | 39 +- src/backends/native-project-recovery.ts | 3 +- src/backends/native-project-restore.ts | 9 +- .../native-project-retained-images.ts | 15 +- .../native-project-retained-startup.ts | 9 +- src/backends/native-project-review.ts | 30 +- src/backends/native-project-run.ts | 13 +- src/backends/native-project-selection.ts | 9 +- src/backends/native-project-start.ts | 46 +- src/backends/native-runtime-client.ts | 29 +- src/backends/native-runtime-metadata.ts | 9 +- src/commands/dispatch.ts | 2 +- src/lib/native-authored-command.ts | 32 +- src/lib/native-up-refusals.ts | 19 +- tests/cli-closed-error-output.test.ts | 38 ++ tests/cli-passthrough-argv.test.ts | 3 + tests/helpers/closed-refusal.ts | 75 +++ .../native-authored-command-dispatch.test.ts | 25 +- tests/native-authored-command.test.ts | 46 +- tests/native-authored-project-start.test.ts | 34 ++ tests/native-https-port.test.ts | 7 +- tests/native-project-cache.test.ts | 40 +- tests/native-project-cleanup-command.test.ts | 2 + tests/native-project-dependencies.test.ts | 122 +++-- .../native-project-dependency-refresh.test.ts | 15 +- tests/native-project-down.test.ts | 66 +-- tests/native-project-exec.test.ts | 50 +- tests/native-project-finalization.test.ts | 97 ++-- tests/native-project-https.test.ts | 85 ++-- ...native-project-interrupted-cleanup.test.ts | 36 +- tests/native-project-network.test.ts | 5 +- tests/native-project-one-off.test.ts | 42 +- tests/native-project-process.test.ts | 29 +- tests/native-project-recovery.test.ts | 66 +-- tests/native-project-restart.test.ts | 85 ++-- tests/native-project-restore.test.ts | 15 +- tests/native-project-retained-images.test.ts | 36 +- tests/native-project-retained-startup.test.ts | 109 +++++ tests/native-project-review.test.ts | 85 +++- tests/native-project-run.test.ts | 40 +- tests/native-project-start.test.ts | 139 ++++++ tests/native-refusal-cli.test.ts | 452 ++++++++++++++++++ tests/native-runtime-client.test.ts | 47 +- tests/native-runtime-metadata.test.ts | 30 +- tests/native-runtime-preflight.test.ts | 2 + tests/native-selection-refusals.test.ts | 58 +++ 57 files changed, 1870 insertions(+), 489 deletions(-) create mode 100644 tests/helpers/closed-refusal.ts create mode 100644 tests/native-project-retained-startup.test.ts create mode 100644 tests/native-refusal-cli.test.ts create mode 100644 tests/native-selection-refusals.test.ts diff --git a/docs/cli.md b/docs/cli.md index cbccd2fb7..984a384e5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -187,7 +187,8 @@ 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` and `x`, Hack options go before the child command. +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 diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 800b3b11d..540d656e5 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -648,7 +648,13 @@ explicit native selections described below. Unsupported Compose or environment i (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`. Each prints one closed line and drops nothing. Existing +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/native-authored-commands.md b/docs/reference/native-authored-commands.md index 72e325321..a454984df 100644 --- a/docs/reference/native-authored-commands.md +++ b/docs/reference/native-authored-commands.md @@ -96,9 +96,10 @@ 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. -`--detach` refuses with `E_NATIVE_PROJECT_UNSUPPORTED` and says that native `up` runs in -the foreground only: run it without `--detach` in its own terminal, use `ps` and `logs` -from another terminal, and press Ctrl-C to stop it. +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 diff --git a/src/backends/native-https-port.ts b/src/backends/native-https-port.ts index d0c80b2c3..c5a906b6e 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-project-cache.ts b/src/backends/native-project-cache.ts index be57bd391..3156eb901 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 a33c7f9f7..cc92a4273 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 64c37b29f..a0e05d5c6 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-finalization.ts b/src/backends/native-project-finalization.ts index 3271b2461..fd949b136 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 643cfb278..24b5d9c79 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 cc9dade48..0a924f516 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-network.ts b/src/backends/native-project-network.ts index dd2c9bfe8..9549adeca 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-process.ts b/src/backends/native-project-process.ts index 2df300e59..b898d8f54 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, @@ -79,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." ); } @@ -126,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." ); } @@ -186,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." ); } @@ -218,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." ); } @@ -267,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." + ); } } @@ -411,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." ); } @@ -430,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.` ); } @@ -484,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." ); } @@ -520,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; @@ -529,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." ); } @@ -539,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; @@ -569,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 f1d686215..638203c1d 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-restore.ts b/src/backends/native-project-restore.ts index da477f80b..67adc971c 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 f02d1a2c4..2469043d6 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 c612d2dc4..c31109543 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 173084f34..78f307ccc 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-run.ts b/src/backends/native-project-run.ts index d79af2b4d..5ae8a1a50 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 4612988fe..2cc8cf077 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-start.ts b/src/backends/native-project-start.ts index 1af02b5e7..42ec0a623 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -285,6 +285,24 @@ function refused(): Error { }); } +/** + * 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( @@ -358,7 +376,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; } @@ -429,7 +447,7 @@ function authoritative( value.journal_incomplete !== false || !isRecord(value.receipt) ) { - throw refused(); + throw unconfirmed("the startup receipt"); } const receipt = value.receipt; if ( @@ -439,7 +457,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 }; } @@ -461,7 +479,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 ( @@ -476,9 +494,7 @@ function redirectHostname( reviewed.path !== route.path || target.pathname !== route.path ) { - throw new ClosedCliError( - "Native HTTPS verification failed (REDIRECT_TARGET_REFUSED); peer values omitted." - ); + throw httpsVerificationFailed("REDIRECT_TARGET_REFUSED"); } return target.hostname; } @@ -511,22 +527,18 @@ export async function verifyHttpsRoutes( let hostname = route.hostname; while (true) { if (visited.has(hostname)) { - throw new ClosedCliError( - "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 ClosedCliError( - `Native HTTPS verification failed (HTTP_STATUS_${response.statusCode}); peer values omitted.` - ); + throw httpsVerificationFailed(`HTTP_STATUS_${response.statusCode}`); } hostname = redirectHostname( response.location, @@ -809,7 +821,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) && @@ -1222,7 +1236,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({ diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 5ad29c87b..4e729fa11 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 { @@ -82,6 +82,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. */ @@ -143,14 +145,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) { @@ -446,7 +457,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." + ); } } @@ -485,7 +498,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." ); } @@ -512,7 +525,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." ); } diff --git a/src/backends/native-runtime-metadata.ts b/src/backends/native-runtime-metadata.ts index e110da36b..5b0559e38 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/commands/dispatch.ts b/src/commands/dispatch.ts index 759bab925..a6873834b 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/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 31325b741..a32080ee0 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -45,27 +45,16 @@ import { createNativeEnvFingerprintSession, type NativeEnvFingerprintSession, } from "./native-env-fingerprint-state.ts"; +import { nativeForegroundDetachRefusal } from "./native-up-refusals.ts"; -function unsupported(hint?: string): never { +function unsupported(): never { throw new HackCliError({ code: "E_NATIVE_PROJECT_UNSUPPORTED", - message: `Native authored execution requires whole-project foreground up or restart, owner-mediated down, ps, finite single-service logs --no-follow, noninteractive single-service exec or run, or explicit stored-generation down --recover on macOS. This request ran no input or runtime operation.${hint ? ` ${hint}` : ""}`, + message: + "Native authored execution requires whole-project foreground up or restart, owner-mediated down, ps, finite single-service logs --no-follow, noninteractive single-service exec or run, or explicit stored-generation down --recover on macOS. This request ran no input or runtime operation.", }); } -/** The supported alternative to a detached native foreground start. */ -function foregroundDetachHint( - options: NativeComposeCommandOptions -): string | undefined { - if ( - !(options.detach && ["up", "restart"].includes(options.operation)) || - process.platform !== "darwin" - ) { - return; - } - return `Native \`hack ${options.operation}\` runs in the foreground only, so --detach is not supported: run it 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.`; -} - function assertLogsOptions(options: NativeComposeCommandOptions): void { if ( process.platform !== "darwin" || @@ -194,6 +183,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) || @@ -213,7 +213,7 @@ function assertForegroundOptions(options: NativeComposeCommandOptions): void { options.overlay !== undefined || options.services !== undefined)) ) { - unsupported(foregroundDetachHint(options)); + unsupported(); } } diff --git a/src/lib/native-up-refusals.ts b/src/lib/native-up-refusals.ts index 0427481b8..2bd7d93a8 100644 --- a/src/lib/native-up-refusals.ts +++ b/src/lib/native-up-refusals.ts @@ -7,6 +7,21 @@ import { HackCliError } from "./cli-result.ts"; * 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; @@ -19,9 +34,7 @@ export function refuseUnsupportedNativeUpOptions(opts: { throw new HackCliError({ code: "E_USAGE", message }); }; if (opts.detach) { - refuse( - `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.` - ); + throw nativeForegroundDetachRefusal({ restart: opts.restart }); } if (opts.json) { refuse( diff --git a/tests/cli-closed-error-output.test.ts b/tests/cli-closed-error-output.test.ts index edd9e1d30..a31432fa2 100644 --- a/tests/cli-closed-error-output.test.ts +++ b/tests/cli-closed-error-output.test.ts @@ -1,5 +1,8 @@ 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"; @@ -209,3 +212,38 @@ test.each([ 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-passthrough-argv.test.ts b/tests/cli-passthrough-argv.test.ts index 98aa5347f..623429e1c 100644 --- a/tests/cli-passthrough-argv.test.ts +++ b/tests/cli-passthrough-argv.test.ts @@ -45,6 +45,8 @@ const PASSTHROUGH = [ { 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) { @@ -218,6 +220,7 @@ test.each([ [["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) => { diff --git a/tests/helpers/closed-refusal.ts b/tests/helpers/closed-refusal.ts new file mode 100644 index 000000000..a4b408779 --- /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 9637a6bdd..30d1a6c63 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 46ce7a186..a8741e12f 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([]); }); @@ -541,22 +551,36 @@ macTest.each([ } ); -macTest( - "a detached native up names the foreground-only contract and its alternative", - async () => { +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, detach: true }, + 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_NATIVE_PROJECT_UNSUPPORTED"); - expect(value).toHaveProperty( - "message", - expect.stringContaining( - "Native `hack up` runs in the foreground only, so --detach is not supported: run it without --detach in its own terminal" - ) + 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"); } ); diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 1ca15edaa..3f70868c4 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -27,6 +27,7 @@ import { NATIVE_STORAGE_TOOL_MANIFEST, nativeStorageToolManifest, } from "../src/backends/native-authored-storage-tool.ts"; +import { nativeCodesReportRuntimeNotRunning } from "../src/backends/native-runtime-client.ts"; import { type ComposeEnvFingerprintRecord, evaluateComposeServiceEnvFreshness, @@ -220,6 +221,12 @@ type FixtureOptions = { 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" @@ -418,6 +425,7 @@ 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)){ @@ -649,6 +657,32 @@ macTest( } ); +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 () => { diff --git a/tests/native-https-port.test.ts b/tests/native-https-port.test.ts index a9b2bab2f..4f45fdd5a 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-project-cache.test.ts b/tests/native-project-cache.test.ts index a547abb0d..23f3d256e 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 f05526e54..b1c7d7525 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 ce5341681..3dc259894 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 70cdc0edc..c235d872d 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 2ef474f2b..df41b036f 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 17808bda3..798b006f9 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 23b98396c..3992fe548 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 dd5a519d9..58d05b5f1 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-interrupted-cleanup.test.ts b/tests/native-project-interrupted-cleanup.test.ts index 41dced37f..ea9ea5ce5 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 2c6f20179..ec7cf6969 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-one-off.test.ts b/tests/native-project-one-off.test.ts index fa734657f..f792f40ef 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 919ea5b03..49e170325 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 4a0b7dbd3..4bf9ae250 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 a3650f72c..c6450adbe 100644 --- a/tests/native-project-restart.test.ts +++ b/tests/native-project-restart.test.ts @@ -28,6 +28,10 @@ import { 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), @@ -373,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: { @@ -386,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 () => { @@ -411,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, @@ -459,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", @@ -504,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, @@ -519,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"]); } }); @@ -753,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 () => @@ -768,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(); @@ -785,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 () => @@ -811,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(); @@ -826,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 () => @@ -847,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", @@ -941,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 () => @@ -971,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(); @@ -1146,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 }) => @@ -1163,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 }) => @@ -1186,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 }); @@ -1198,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 }) => { @@ -1209,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", @@ -1241,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 }) => @@ -1250,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", @@ -1379,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 7e539d6ba..c1dbee853 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 451653355..39129db46 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 000000000..590540ee7 --- /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 4e5d52d0e..ae6e5d89f 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-run.test.ts b/tests/native-project-run.test.ts index 50ddb1e70..ace1adadb 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-start.test.ts b/tests/native-project-start.test.ts index 3b4b9f0ac..705b09b77 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -521,6 +521,145 @@ test("a canceled start reports cancellation, not the configuration refusal", asy 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(); const refusal = await startNativeProject({ diff --git a/tests/native-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts new file mode 100644 index 000000000..8ba6a95f7 --- /dev/null +++ b/tests/native-refusal-cli.test.ts @@ -0,0 +1,452 @@ +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() { + 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 +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) + ); + 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, reply, 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(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 +); diff --git a/tests/native-runtime-client.test.ts b/tests/native-runtime-client.test.ts index 8374f6c66..3b9493782 100644 --- a/tests/native-runtime-client.test.ts +++ b/tests/native-runtime-client.test.ts @@ -10,6 +10,10 @@ import { 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 () => { @@ -34,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(); @@ -540,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 () => { @@ -842,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 3022b4508..8504538f4 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-preflight.test.ts b/tests/native-runtime-preflight.test.ts index a09191f7a..38f41ad06 100644 --- a/tests/native-runtime-preflight.test.ts +++ b/tests/native-runtime-preflight.test.ts @@ -26,6 +26,8 @@ async function preflight(reply: () => Promise) { 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 }, diff --git a/tests/native-selection-refusals.test.ts b/tests/native-selection-refusals.test.ts new file mode 100644 index 000000000..c688d7e23 --- /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." + ); + } +}); From f8e823e6d40dbc24f9a8201a1f9cf8e7b5d3bc66 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 20 Oct 2026 09:37:57 -0400 Subject: [PATCH 4/5] test(cli): expect the shared detach refusal in native plan CLI checks On macOS, authored up --detach now refuses with the image project's one-line E_USAGE message. The native plan CLI acceptance scripts expected E_NATIVE_PROJECT_UNSUPPORTED on every platform; expect E_USAGE on macOS and keep E_NATIVE_PROJECT_UNSUPPORTED elsewhere. Co-Authored-By: Claude Opus 5.5 --- scripts/check-native-acquisition-plan-cli.ts | 8 ++++++-- scripts/check-native-endpoint-plan-cli.ts | 8 ++++++-- scripts/check-native-env-plan-cli.ts | 6 ++++-- scripts/check-native-host-plan-cli.ts | 8 ++++++-- scripts/check-native-process-plan-cli.ts | 8 ++++++-- scripts/check-native-routing-plan-cli.ts | 8 ++++++-- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/scripts/check-native-acquisition-plan-cli.ts b/scripts/check-native-acquisition-plan-cli.ts index 1dd8bbc59..cdf73353c 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 6432965d6..1b5068e47 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 d1789f99c..89a51ade5 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 75ea1d3fa..1b594cad2 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 1cdcddebd..7d0f3aae6 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 00d2fbc58..eaeb9bf50 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( !( From ead8383ea9d2a28376fc2121b820fe75ef9f3655 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 20 Oct 2026 12:59:15 -0400 Subject: [PATCH 5/5] fix(native): close image admission, prepared-base and workspace-cache refusals Three image-project `up` refusals still printed `ERROR: Error: ...` with a stack. They now print one closed line: - An image reference the runtime refuses (`registry_image`) or an authored service without an image is `E_NATIVE_PROJECT_UNSUPPORTED`. A malformed `runtime admit-image` reply is a closed integrity error, and a canceled admission no longer reports a reference refusal. - An invalid HACK_NATIVE_PREPARED_BASE / _STORE selection is `E_CONFIG_INVALID`. - An invalid workspace cache layout in HACK_NATIVE_ADAPTATION is `E_CONFIG_INVALID`, like its sibling adaptation refusals. Spawn tests drive each case through the real CLI with a stub runtime. Co-Authored-By: Claude Opus 5.5 --- src/backends/native-prepared-base.ts | 20 ++-- .../native-project-image-admission.ts | 32 ++++-- src/backends/native-workspace-cache.ts | 12 ++- tests/native-prepared-base.test.ts | 7 +- tests/native-refusal-cli.test.ts | 99 ++++++++++++++++++- 5 files changed, 145 insertions(+), 25 deletions(-) diff --git a/src/backends/native-prepared-base.ts b/src/backends/native-prepared-base.ts index c6c2d88f4..f5a7e4ae7 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-image-admission.ts b/src/backends/native-project-image-admission.ts index 970ae42df..3e032ef2f 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-workspace-cache.ts b/src/backends/native-workspace-cache.ts index 8b2c79c4c..e1c9d0237 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/tests/native-prepared-base.test.ts b/tests/native-prepared-base.test.ts index a7d322f8a..111de5654 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-refusal-cli.test.ts b/tests/native-refusal-cli.test.ts index 8ba6a95f7..a89d07381 100644 --- a/tests/native-refusal-cli.test.ts +++ b/tests/native-refusal-cli.test.ts @@ -53,7 +53,7 @@ afterEach(async () => { } }); -async function fixture() { +async function fixture(compose = COMPOSE) { const root = await realpath( await mkdtemp(join(tmpdir(), "native-refusal-cli-")) ); @@ -67,7 +67,7 @@ async function fixture() { await mkdir(nativeHome, { mode: 0o700 }); await mkdir(runtimeDir); await mkdir(stub); - await writeFile(join(projectDir, "docker-compose.yml"), COMPOSE); + await writeFile(join(projectDir, "docker-compose.yml"), compose); await writeFile( join(projectDir, "hack.config.json"), JSON.stringify({ @@ -83,6 +83,7 @@ async function fixture() { `#!/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 `, @@ -94,6 +95,12 @@ exit 1 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") @@ -111,7 +118,7 @@ exit 1 ...extra, }, }); - return { root, project, projectDir, scope, reply, calls, env }; + return { root, project, projectDir, scope, compose, reply, fail, calls, env }; } type Fixture = Awaited>; @@ -166,7 +173,7 @@ async function scriptUpThroughReview( f: Fixture, plan: Record = {} ) { - const sha = createHash("sha256").update(COMPOSE).digest("hex"); + const sha = createHash("sha256").update(f.compose).digest("hex"); await f.reply("runtime check-project-share", { source_admitted: true, pool_initialized: true, @@ -450,3 +457,87 @@ test( }, 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 +);