From 5129aeb6cc30dbacf0505de39df8b437fdc49244 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 22:27:07 -0400 Subject: [PATCH 1/9] test(dns): cover the native DNS activate/deactivate administrator prompt and add the env secrets threat model The `hack global dns activate|deactivate` authorize closures move into createNativeDnsAuthorizer (src/lib/native-domain-dns-authorization.ts) with injectable interactivity, confirmation and administrator prompt. A failed sudo -v now throws NativeDnsAdministratorAuthorizationError (closed outcome "authorization-failed" and failure class; message unchanged). Tests wire the authorizer into the real activation/deactivation flows and host effect boundary and prove: sudo -v runs before every privileged and file effect, a failed prompt is authorization-failed (not denied) and changes nothing, and a decline or a non-interactive run never reaches sudo. Adds docs/security/env-secrets-threat-model.md (HACK-1127), linked from docs/env.md. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 3 + docs/security/env-secrets-threat-model.md | 157 ++++++ src/commands/global.ts | 54 +-- src/lib/native-domain-dns-authorization.ts | 115 +++++ tests/native-domain-dns-authorization.test.ts | 459 ++++++++++++++++++ 5 files changed, 740 insertions(+), 48 deletions(-) create mode 100644 docs/security/env-secrets-threat-model.md create mode 100644 src/lib/native-domain-dns-authorization.ts create mode 100644 tests/native-domain-dns-authorization.test.ts diff --git a/docs/env.md b/docs/env.md index 0729a7fb6..584a41bc9 100644 --- a/docs/env.md +++ b/docs/env.md @@ -482,6 +482,8 @@ When you pass `--env` or `--service`, Hack creates a stable scoped workspace nam ## Secret key handling Secret values in the YAML files are encrypted with the project key. +The [env and secrets threat model](security/env-secrets-threat-model.md) lists what +this protects against and the gaps that are still open. Default behavior: @@ -640,6 +642,7 @@ If you are writing new docs or new project setup flows, document the YAML overla - [Initialize a project](guides/init-project.md) - [Sessions](sessions.md) - [CLI reference](cli.md) +- [Env and secrets threat model](security/env-secrets-threat-model.md): assets, trust boundaries, current mitigations and open gaps - [Pulumi-style env config design](plans/2026-03-27-pulumi-style-env-config-design.md) diff --git a/docs/security/env-secrets-threat-model.md b/docs/security/env-secrets-threat-model.md new file mode 100644 index 000000000..dea4e2144 --- /dev/null +++ b/docs/security/env-secrets-threat-model.md @@ -0,0 +1,157 @@ +# Env and secrets threat model + +This document describes how Hack protects project env values and secret keys on a +developer machine. It lists the assets, the trust boundaries, the threats, the +mitigations that are merged on `next`, and the gaps that are still open. It tracks +Linear issue HACK-1127. + +For day-to-day usage, see [Env & secrets](../env.md). This page does not change any +behavior. Where it names code, the code is the source of truth. + +## Scope + +In scope: + +- the modern YAML env model (`secretsprovider: project_key`) and its project key +- the legacy secret stores still reachable through `hack env backend`: `keychain`, + `encrypted_file`, and the `cloud` shim that uses `encrypted_file` +- the paths that deliver values: runtime injection into Compose and host commands, + `hack env materialize`, `hack env get`, sessions, and the MCP server +- secret values that come from an external manager such as 1Password + +Out of scope: the retired hosted account, dashboard and ticket flows, and the +unsupported remote, gateway, node and dispatch surfaces. One residual risk in retained +node code is listed under [open gaps](#open-gaps) so that it is not lost. + +## Assets + +| Asset | Where it lives | What it protects | +| --- | --- | --- | +| Project env key | `.hack.secret.key` in the checkout, a shared key under the git common dir, the primary checkout's key (linked worktrees), or `HACK_ENV_SECRET_KEY` | Every `secure: v1:...` value in `.hack/hack.env.*.yaml`. The AES-256-GCM key is the SHA-256 of the key text. | +| Encrypted YAML values (`project_key`) | `.hack/hack.env..yaml`, usually committed | Secret values at rest in git. Plaintext scalars in the same files are not secret. | +| `encrypted_file` store and its key | Store file chosen in config; key from `HACK_SECRETS_FILE_KEY`, `~/.hack/secrets-file.key`, or the keychain service `hack-secrets-backend` | Legacy backend values, including the `cloud` shim. | +| `keychain` store | macOS keychain through `Bun.secrets`, one service per project | Legacy backend values. | +| Values from an external manager (for example 1Password) | The manager's vault, until the user pipes a value into Hack or injects `HACK_ENV_SECRET_KEY` into a process | Hack has no first-party 1Password integration. Custody passes to Hack's stores at the moment of the pipe or injection. | +| Decrypted values in flight | Process environments of Compose containers, host commands, lifecycle processes and session shells; `.hack/.env`; `.hack/.internal/compose.env.override.yml` | The resolved plaintext, after decryption. | + +## Trust boundaries + +1. **Host user account.** Hack runs as the developer. Anything else that runs as the + same user (editors, language servers, project dependencies, agents) can read the + same files and keychain items the user can. Hack does not try to defend against + code that already runs as the user. It does try to keep secrets out of places + that other users, other machines, or later readers can reach: argv, logs, shared + files, git, and published build output. +2. **Containers.** Compose containers receive values as process environment. Anyone + who can reach the Docker engine can read them with `docker inspect`. Container + images and layers must never contain the project key. +3. **Agents and MCP.** An agent acts with the user's rights, but its prompts, + transcripts and tool output may leave the machine. Hack treats the agent context + as a sink that must not receive secret values unless the user asks for one. +4. **CI and managed containers.** CI receives the key only as `HACK_ENV_SECRET_KEY` + from the CI secret store. The key file is never baked into an image. +5. **Repository inputs.** A cloned repository is untrusted input. It may contain + symlinks, malformed ciphertext, oversized files or a committed key. + +## Threats and current mitigations + +Each row names the merged change and the file that carries the control. PR numbers +refer to `hack-dance/hack`. + +### Leakage through argv, logs and process listings + +| Threat | Mitigation on `next` | +| --- | --- | +| A secret passed as a command argument lands in shell history and in `ps` output that other local users can read. | `--value-stdin` on `hack env add` and `hack env set` reads the value from stdin (at most 64 KiB, fixed refusals that never repeat the input). See `src/lib/env-value-stdin.ts` (`readEnvValueFromStdin`, `assertEnvValueStdinUsage`), #308. The hidden prompt stays available in a terminal. | +| A mistyped `KEY=value` argument is echoed back in an error, or turned into a file name or overlay name. | Key, scope and `--env` arguments are validated before any file access, and the refusal text never contains the argument: `src/cli/env-arguments.ts`, `src/cli/env-selection.ts`, `parseEnvConfigSelection` in `src/lib/project.ts`, the shared check in `src/cli/run.ts` (#308). `--project` and `--target` follow the same rule through `src/cli/project-selector.ts` (`parseProjectSelectorOption`, `unknownProjectMessage`, `resolveRegisteredProjectOption`) and the `runCli` check (#318). Proven by `tests/env-key-argument-redaction-cli.test.ts` and `tests/project-target-argument-redaction-cli.test.ts`. | +| Diagnostics print key bytes or absolute paths. | The `hack doctor` env key check uses closed codes and project-relative subjects only: `src/lib/doctor-env-key.ts` (#309). Envelope and key-file failures use one fixed message: `src/lib/private-file.ts`, `src/lib/project-env-config.ts`, `src/lib/secret-store.ts` (#288). | +| Host command records keep arguments or env values. | `hack host exec` and `hack env exec` records are payload-free (see [host command lifetime](../env.md#host-command-lifetime-and-diagnostics)). | +| Tests leak the contributor's real `HOME`, keychain, Docker config or `HACK_*` variables into a spawned CLI. | `tests/helpers/private-cli-env.ts` (`privateCliEnv`) builds the whole child environment, and `tests/cli-spawn-env-isolation.test.ts` fails any source-CLI spawn that copies the runner environment (#319). | + +### Symlinks, TOCTOU and file modes + +| Threat | Mitigation on `next` | +| --- | --- | +| A repository plants `.hack.secret.key` as a symlink (dangling or not) so that Hack reads or writes a file it should not. | Key reads go through `readBoundedPrivateFile` (`lstat`, `O_NOFOLLOW`, descriptor and path identity checks, 1 MiB bound, fatal UTF-8). New keys are created with `createPrivateFileExclusive` (`O_CREAT|O_EXCL|O_NOFOLLOW`, mode 0600, `fchmod`, `fsync`). Both are in `src/lib/private-file.ts`, used by `resolveProjectEnvKey`, `ensureProjectEnvSecretKey` and `resolveInheritedProjectEnvKey` in `src/lib/project-env-config.ts` (#288). | +| A symlinked `.hack/.env`, overlay file or `.hack` directory redirects a write, or a reader sees a partial file. | `src/lib/private-env-file.ts` (`readPrivateEnvFile`, `writePrivateEnvFileIfChanged`) and `replacePrivateFileAtomic` in `src/lib/private-file.ts`: a private sibling is written and fsynced, then renamed over the target; symlinks and non-regular entries are refused with one fixed message (#302). Used by `src/lib/hack-env.ts` and `materializeProjectEnv`. | +| A world-readable env file or store. | `.hack/.env`, overlays and the `encrypted_file` store are written 0600, and a same-content file with group or other access is tightened (#302). The `hack doctor` env key check reports `env-key-weak-permissions` and `env-key-wrong-owner` (#309). | +| Two writers of the `encrypted_file` store lose an update. | An in-process queue plus a cooperative `.lock` using the owner protocol from `src/lib/projects-registry-lock.ts`; a symlinked store path is refused for writes (`src/lib/secret-store.ts`, #302). | + +### Malformed or hostile ciphertext + +| Threat | Mitigation on `next` | +| --- | --- | +| Truncated tags, oversized ciphertext or non-canonical base64 cause a wrong decision or a crash. | `decryptProjectEnvValue` (`src/lib/project-env-config.ts`) and `parseEncryptedEnvelope`/`decryptStore` (`src/lib/secret-store.ts`) require canonical base64 (`decodeCanonicalBase64` in `src/lib/private-file.ts`), a 12-byte IV and a 16-byte tag, pin `authTagLength: 16`, bound the size before decoding, and fail with one fixed error (#288). | +| Ciphertext is moved to another variable, scope or file and still decrypts. | Not prevented. The current v1 format authenticates no associated data. `tests/aes-gcm-v1-context-binding.test.ts` pins this behavior so that any change is deliberate (#302). See [open gaps](#open-gaps). | + +### Public-artifact and git leakage + +| Threat | Mitigation on `next` | +| --- | --- | +| The key is committed. | The root `.gitignore` gets a `.hack.secret.key` entry when the key is created. Encryption refuses a git-tracked key, including an inherited primary key, before it is copied (#288). `hack doctor` reports `env-key-tracked` at any depth (#309) and the "generated files" check can untrack a root key with `--fix`. | +| A key copy ships in build output or a static site. | `hack doctor` reports `env-key-public-path` for copies under `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` and `.output/` (#309). | +| A weak, short or repetitive key. | `env-key-wrong-length` and `env-key-low-entropy` (#309). Generated keys are 32 random bytes in base64url. | +| Materialized `.hack/.env` or local overrides are committed. | Hack owns a committed `.hack/.gitignore` block, and `hack doctor` flags tracked generated files (see [ignore rules](../env.md#ignore-rules-committed-hackgitignore)). | +| A dependency vulnerability ships in Hack. | `bun install --frozen-lockfile`, and the read-only `scripts/dependabot-inventory.ts` lists open alerts (#309). Hack does not sandbox project dependencies that run under `hack host exec`; they receive the injected env by design. | + +### Agent and MCP exfiltration + +| Threat | Mitigation on `next` | +| --- | --- | +| An agent puts a secret in a prompt, an argument or a log. | Canonical agent guidance in `src/agents/instruction-source.ts` says to pipe secrets with `--value-stdin` or use the hidden prompt, to treat `hack env get` and `--show-secrets` as plaintext sinks, and never to repeat their output (#308). The generated plugin bundle and checked-in examples render the same text. | +| An MCP tool returns secret values. | `hack.project.env.keys` and `hack.project.env.explain` in `src/mcp/server.ts` return names and provenance only; `buildEnvKeysArgs` never emits `--show-secrets`, and child stdout and stderr are withheld. | +| A compromised dependency or agent reads the key file. | Out of reach for Hack inside the same user account (boundary 1). File modes and the symlink rules above only stop other users and redirected writes. | + +## Open gaps + +These gaps are known and not closed on `next`. Each one needs its own change. + +1. **Crash-safe key rotation.** Rotation is manual: move the key aside and re-add each + secret. A crash or a partial run leaves files encrypted under two different keys, + and Hack has no journal or resume step for this. There is also no command that + rotates the `encrypted_file` store key. +2. **Crypto decision record pending independent review.** The KDF (a single SHA-256 + of the key text), the missing AAD binding, and the v1 envelope format are + characterized in tests but have no written decision record. #288 was flagged for an + independent crypto review (WU14) that has not happened yet. Any format change must + keep v1 values readable. +3. **Live keychain and 1Password custody receipts.** No test or receipt proves, on a + real macOS host, that values stored through the `keychain` backend or piped from + 1Password stay only in the expected custody: the keychain item, the encrypted YAML + or store, and the target process environment. The CLI tests disable the keychain + fallback (`HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`) and never call 1Password. +4. **Plaintext Compose override.** For the Compose runtime, `src/commands/project.ts` + writes resolved values, including decrypted secrets, to + `.hack/.internal/compose.env.override.yml` with `writeTextFileIfChanged` from + `src/lib/fs.ts`. That call uses `Bun.write`, so the file gets the process umask + mode (often 0644) and a symlink at that path is followed. The #302 rules do not + cover it. +5. **YAML env file writes.** `writeProjectEnvConfigFile` in + `src/lib/project-env-config.ts` also uses `writeTextFileIfChanged`, so + `.hack/hack.env.*.yaml` writes follow a symlink at the file. These files hold + ciphertext and non-secret plaintext, but a hostile repository could redirect the + write. +6. **`encrypted_file` key file hygiene.** `readEncryptedFileKey` and + `writeEncryptedFileKey` in `src/lib/secret-store.ts` read with `readTextFile` and + write with `writeFile` plus `chmod`. They do not use the `src/lib/private-file.ts` + helpers, so they follow symlinks and do not create the key exclusively. +7. **Directory TOCTOU.** The #302 directory checks are path-based `lstat` with + identity rechecks, not `openat`/`renameat`. A swap of `.hack` between the recheck + and `rename(2)` is not fully closed. The store lock is cooperative, and v4 writers + do not honor it. +8. **MCP command output.** `hack.project.run`, `hack.project.exec` and the log tools + return child output. A command such as `printenv`, or an app that logs its env, + puts injected values into the agent context. The only gates are the client's + approval for destructive tools and the agent guidance. +9. **Container env visibility.** Values in container environments are visible to + anyone with Docker engine access (`docker inspect`). This is inherent to env + injection. +10. **Retained node code (unsupported).** `src/lib/nodes-registry.ts` stores node + tokens with `security add-generic-password ... -w `, which puts the value in + argv for the life of that process. The node surface is unsupported; fix or remove + it before that surface returns. + +## Related + +- [Env & secrets](../env.md) +- [CLI reference](../cli.md) diff --git a/src/commands/global.ts b/src/commands/global.ts index 26fd82073..d85f00fb7 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -57,11 +57,6 @@ import { getLaunchdServiceStatus } from "../daemon/launchd.ts"; import { resolveDaemonPaths } from "../daemon/paths.ts"; import { isProcessRunning } from "../daemon/process.ts"; import { readDaemonStatus } from "../daemon/status.ts"; -import { - ADMINISTRATOR_PROMPT_HINT, - describeAdministratorAuthorizationFailure, - requestAdministratorAuthorization, -} from "../lib/admin-authorization.ts"; import { resolveGlobalConfigPath, resolveGlobalHackDir, @@ -106,6 +101,7 @@ import { } from "../lib/mutagen.ts"; import type { NativeDnsActivationInspection } from "../lib/native-domain-dns-activate.ts"; import { activateNativeDomainDns } from "../lib/native-domain-dns-activate.ts"; +import { createNativeDnsAuthorizer } from "../lib/native-domain-dns-authorization.ts"; import { deactivateNativeDomainDns } from "../lib/native-domain-dns-deactivate.ts"; import { createNativeDnsHostDependencies } from "../lib/native-domain-dns-host.ts"; import { prepareNativeDnsLaunchdRestart } from "../lib/native-domain-dns-launchd.ts"; @@ -536,20 +532,7 @@ async function handleGlobalDnsActivate({ } return current.inspection; }; - const authorize = async () => { - if (!isInteractiveTerminal()) { - return false; - } - const accepted = await confirmSafe({ - message: `Activate only *.${domain} at 127.0.0.1 via a new dnsmasq claim and /etc/resolver file? This restarts dnsmasq; existing v4 suffixes remain unchanged.`, - initialValue: false, - nonInteractive: "decline", - }); - if (!accepted) { - return false; - } - return await requireNativeAdministratorAuthorization(); - }; + const authorize = createNativeDnsAuthorizer({ action: "activate", domain }); const deps = createNativeDnsHostDependencies({ receiptPath: initial.receiptPath, dnsmasqPath: initial.inspection.plan.dnsmasqPath, @@ -655,20 +638,10 @@ async function handleGlobalDnsDeactivate({ } return current.inspection; }; - const authorize = async () => { - if (!isInteractiveTerminal()) { - return false; - } - const accepted = await confirmSafe({ - message: `Deactivate only the owned *.${domain} DNS claim? This restarts dnsmasq; the existing v4 parent remains unchanged.`, - initialValue: false, - nonInteractive: "decline", - }); - if (!accepted) { - return false; - } - return await requireNativeAdministratorAuthorization(); - }; + const authorize = createNativeDnsAuthorizer({ + action: "deactivate", + domain, + }); const deps = createNativeDnsHostDependencies({ receiptPath: initial.receiptPath, dnsmasqPath: initial.inspection.plan.dnsmasqPath, @@ -2338,21 +2311,6 @@ async function runMacPrivilegedCommand(opts: { }); } -/** - * Native administrator prompt after an approved DNS change. A failed prompt - * throws, so it is reported as an authorization failure and never as the - * user's decline. - */ -async function requireNativeAdministratorAuthorization(): Promise { - const result = await requestAdministratorAuthorization(); - if (result.status === "failed") { - throw new Error( - `macOS administrator authorization did not complete (${describeAdministratorAuthorizationFailure(result)}); nothing changed. ${ADMINISTRATOR_PROMPT_HINT}` - ); - } - return true; -} - function isInteractiveTerminal(): boolean { return process.stdin.isTTY === true; } diff --git a/src/lib/native-domain-dns-authorization.ts b/src/lib/native-domain-dns-authorization.ts new file mode 100644 index 000000000..3c67906e0 --- /dev/null +++ b/src/lib/native-domain-dns-authorization.ts @@ -0,0 +1,115 @@ +import { + ADMINISTRATOR_PROMPT_HINT, + type AdministratorAuthorization, + type AdministratorAuthorizationFailure, + describeAdministratorAuthorizationFailure, + requestAdministratorAuthorization, +} from "./admin-authorization.ts"; +import { confirmSafe } from "./interactivity.ts"; +import type { NativeDnsActivationDependencies } from "./native-domain-dns-activate.ts"; + +/** The `hack global dns` change a native DNS authorizer approves. */ +export type NativeDnsAuthorizationAction = "activate" | "deactivate"; + +/** + * The native administrator prompt did not complete after the user approved the + * change. Carries only the closed failure class, never sudo output. + */ +export class NativeDnsAdministratorAuthorizationError extends Error { + readonly outcome = "authorization-failed" as const; + readonly failure: AdministratorAuthorizationFailure; + + constructor( + result: Extract + ) { + super( + `macOS administrator authorization did not complete (${describeAdministratorAuthorizationFailure(result)}); nothing changed. ${ADMINISTRATOR_PROMPT_HINT}` + ); + this.name = "NativeDnsAdministratorAuthorizationError"; + this.failure = result.failure; + } +} + +/** + * Native administrator prompt after an approved DNS change. A failed prompt + * throws, so it is reported as an authorization failure and never as the + * user's decline. + */ +export async function requireNativeAdministratorAuthorization( + opts: { + readonly requestAdministrator?: () => Promise; + } = {} +): Promise { + const request = + opts.requestAdministrator ?? + (async () => await requestAdministratorAuthorization()); + const result = await request(); + if (result.status === "failed") { + throw new NativeDnsAdministratorAuthorizationError(result); + } + return true; +} + +export interface NativeDnsAuthorizerDependencies { + /** True only when a human can answer the confirmation and the sudo prompt. */ + readonly isInteractive: () => boolean; + readonly confirm: (message: string) => Promise; + /** Shows the native `sudo -v` prompt; later privileged steps use `sudo -n`. */ + readonly requestAdministrator: () => Promise; +} + +const DEFAULTS: NativeDnsAuthorizerDependencies = { + isInteractive: () => process.stdin.isTTY === true, + confirm: async (message) => + await confirmSafe({ + message, + initialValue: false, + nonInteractive: "decline", + }), + requestAdministrator: async () => await requestAdministratorAuthorization(), +}; + +/** Confirmation text naming exactly the owned claim the change touches. */ +export function nativeDnsConfirmationMessage(opts: { + readonly action: NativeDnsAuthorizationAction; + readonly domain: string; +}): string { + if (opts.action === "activate") { + return `Activate only *.${opts.domain} at 127.0.0.1 via a new dnsmasq claim and /etc/resolver file? This restarts dnsmasq; existing v4 suffixes remain unchanged.`; + } + return `Deactivate only the owned *.${opts.domain} DNS claim? This restarts dnsmasq; the existing v4 parent remains unchanged.`; +} + +/** + * Authorizer for `hack global dns activate|deactivate`. + * + * Contract: + * - Without an interactive terminal, or when the user declines, it returns + * false (a decline) and never reaches sudo. + * - After an approval it shows the native administrator prompt. A failed + * prompt throws NativeDnsAdministratorAuthorizationError instead of + * returning false, so the caller reports authorization-failed, not denied. + * - The activation and deactivation flows call it before any privileged step, + * so no `sudo -n` step runs before the prompt succeeds. + */ +export function createNativeDnsAuthorizer(opts: { + readonly action: NativeDnsAuthorizationAction; + readonly domain: string; + readonly dependencies?: Partial; +}): NativeDnsActivationDependencies["authorize"] { + const deps = { ...DEFAULTS, ...opts.dependencies }; + return async () => { + if (!deps.isInteractive()) { + return false; + } + const accepted = await deps.confirm( + nativeDnsConfirmationMessage({ action: opts.action, domain: opts.domain }) + ); + if (!accepted) { + return false; + } + return await requireNativeAdministratorAuthorization({ + requestAdministrator: deps.requestAdministrator, + }); + }; +} diff --git a/tests/native-domain-dns-authorization.test.ts b/tests/native-domain-dns-authorization.test.ts new file mode 100644 index 000000000..644a3fc29 --- /dev/null +++ b/tests/native-domain-dns-authorization.test.ts @@ -0,0 +1,459 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AdministratorAuthorization } from "../src/lib/admin-authorization.ts"; +import { + activateNativeDomainDns, + NativeDnsActivationError, +} from "../src/lib/native-domain-dns-activate.ts"; +import { + createNativeDnsAuthorizer, + NativeDnsAdministratorAuthorizationError, + type NativeDnsAuthorizationAction, + requireNativeAdministratorAuthorization, +} from "../src/lib/native-domain-dns-authorization.ts"; +import { + deactivateNativeDomainDns, + NativeDnsDeactivationError, +} from "../src/lib/native-domain-dns-deactivate.ts"; +import { + createNativeDnsHostDependencies, + type NativeDnsCommandRunner, + type NativeDnsHostDependencies, +} from "../src/lib/native-domain-dns-host.ts"; +import { + type NativeDnsPlan, + planNativeDomainDns, +} from "../src/lib/native-domain-dns-plan.ts"; + +/* + * `hack global dns activate|deactivate` authorizer wired into the real + * activation/deactivation flows and host effect boundary. Every effect is + * recorded in one ordered log; sudo, dnsmasq and system DNS are fakes and all + * files live in a private temporary directory. + */ + +const DOMAIN = "project.example.test"; +const SUDO_V = "sudo -v"; +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +const AUTHORIZED: AdministratorAuthorization = { status: "authorized" }; +const REJECTED: AdministratorAuthorization = { + status: "failed", + failure: "rejected", + exitCode: 1, +}; + +function recordingAuthorizer(opts: { + readonly action: NativeDnsAuthorizationAction; + readonly log: string[]; + readonly interactive?: boolean; + readonly accept?: boolean; + readonly administrator?: AdministratorAuthorization; +}) { + const prompts: string[] = []; + const authorize = createNativeDnsAuthorizer({ + action: opts.action, + domain: DOMAIN, + dependencies: { + isInteractive: () => opts.interactive ?? true, + confirm: async (message) => { + prompts.push(message); + opts.log.push("confirm"); + return opts.accept ?? true; + }, + requestAdministrator: async () => { + opts.log.push(SUDO_V); + return opts.administrator ?? AUTHORIZED; + }, + }, + }); + return { authorize, prompts }; +} + +async function workspace() { + const root = await mkdtemp(join(tmpdir(), "native-dns-authorization-")); + roots.push(root); + const includeDir = join(root, "dnsmasq.d"); + const resolverDir = join(root, "resolver"); + const privateDir = join(root, "private"); + await Promise.all([ + mkdir(includeDir), + mkdir(resolverDir), + mkdir(privateDir, { mode: 0o700 }), + ]); + return { + root, + includeDir, + resolverDir, + mainPath: join(root, "dnsmasq.conf"), + hostsPath: join(root, "hosts"), + receiptPath: join(privateDir, "native-dns.json"), + }; +} + +type Workspace = Awaited>; + +function planFor( + ws: Workspace, + active?: { readonly plan: NativeDnsPlan } +): NativeDnsPlan { + return planNativeDomainDns({ + domain: DOMAIN, + mainConfig: { + path: ws.mainPath, + content: `conf-dir=${ws.includeDir},*.conf\n`, + }, + includeDir: ws.includeDir, + includeFiles: active + ? [ + { + path: active.plan.dnsmasqPath, + content: active.plan.dnsmasqContent, + }, + ] + : [], + resolverDir: ws.resolverDir, + resolverFiles: active + ? [ + { + path: active.plan.resolverPath, + content: active.plan.resolverContent, + }, + ] + : [], + hosts: { path: ws.hostsPath, content: "127.0.0.1 localhost\n" }, + dnsmasqArgs: ["dnsmasq", "-7", `${ws.includeDir},*.conf`], + receipt: active ? active.plan.activeReceipt : null, + }); +} + +/** + * Host effect boundary whose privileged runner, service restart and cache + * flush (all `sudo -n` in production) and file effects append to `log`. + */ +function recordingHost(opts: { + readonly ws: Workspace; + readonly plan: NativeDnsPlan; + readonly authorize: NativeDnsHostDependencies["authorize"]; + readonly log: string[]; + readonly loopbackAnswer: boolean; +}): NativeDnsHostDependencies { + const runPrivileged: NativeDnsCommandRunner = async (command) => { + const action = command[7] ?? ""; + opts.log.push(`sudo -n resolver ${action}`); + if (action === "remove") { + await rm(command[8] ?? "", { force: true }); + return { exitCode: 0, stdout: "1\n", stderr: "" }; + } + return { exitCode: 0, stdout: "42:73\n", stderr: "" }; + }; + const runCommand: NativeDnsCommandRunner = async (command) => { + if (!opts.loopbackAnswer) { + return { exitCode: 0, stdout: "", stderr: "" }; + } + const stdout = + command[0] === "/usr/bin/dscacheutil" + ? "name: probe\nip_address: 127.0.0.1\n" + : "127.0.0.1\n"; + return { exitCode: 0, stdout, stderr: "" }; + }; + const host = createNativeDnsHostDependencies({ + receiptPath: opts.ws.receiptPath, + dnsmasqPath: opts.plan.dnsmasqPath, + resolverPath: opts.plan.resolverPath, + dnsmasqBinary: join(opts.ws.root, "dnsmasq"), + dnsmasqMainConfigPath: opts.ws.mainPath, + dnsmasqIncludeDir: opts.ws.includeDir, + inspectPlan: async () => ({ plan: opts.plan, fingerprint: "snapshot" }), + authorize: opts.authorize, + restartDnsmasq: async () => { + opts.log.push("sudo -n restart dnsmasq"); + }, + flushDnsCache: async () => { + opts.log.push("sudo -n flush dns cache"); + }, + runCommand, + runPrivileged, + }); + return { + ...host, + async writeReceipt(receipt) { + opts.log.push(`write receipt ${receipt.state}`); + await host.writeReceipt(receipt); + }, + async createExclusive(file) { + opts.log.push(`create ${file.path}`); + return await host.createExclusive(file); + }, + async removeIfOwned(file) { + opts.log.push(`remove ${file.path}`); + return await host.removeIfOwned(file); + }, + }; +} + +/** Publish an active claim on disk without touching the recorded log. */ +async function activeClaim(ws: Workspace) { + const available = planFor(ws); + const setup = createNativeDnsHostDependencies({ + receiptPath: ws.receiptPath, + dnsmasqPath: available.dnsmasqPath, + resolverPath: available.resolverPath, + dnsmasqBinary: join(ws.root, "dnsmasq"), + dnsmasqMainConfigPath: ws.mainPath, + dnsmasqIncludeDir: ws.includeDir, + inspectPlan: async () => ({ plan: available, fingerprint: "setup" }), + authorize: async () => { + throw new Error("setup never authorizes"); + }, + restartDnsmasq: async () => {}, + flushDnsCache: async () => {}, + runCommand: async () => ({ exitCode: 1, stdout: "", stderr: "" }), + runPrivileged: async () => ({ exitCode: 1, stdout: "", stderr: "" }), + }); + await setup.writeReceipt(available.pendingReceipt); + await setup.writeReceipt(available.activeReceipt); + await writeFile(available.dnsmasqPath, available.dnsmasqContent); + await writeFile(available.resolverPath, available.resolverContent); + return planFor(ws, { plan: available }); +} + +function assertAuthorizedFirst(log: readonly string[]): void { + expect(log.slice(0, 2)).toEqual(["confirm", SUDO_V]); + expect(log.filter((entry) => entry === SUDO_V)).toHaveLength(1); + // Non-vacuous: privileged and file effects did run, all after sudo -v. + expect(log.some((entry) => entry.startsWith("sudo -n "))).toBe(true); + expect(log.some((entry) => entry.startsWith("write receipt "))).toBe(true); +} + +test("activate: native administrator authorization precedes every privileged and file effect", async () => { + const ws = await workspace(); + const plan = planFor(ws); + const log: string[] = []; + const { authorize, prompts } = recordingAuthorizer({ + action: "activate", + log, + }); + const active = await activateNativeDomainDns({ + dependencies: recordingHost({ + ws, + plan, + authorize, + log, + loopbackAnswer: true, + }), + }); + expect(active).toEqual(plan.activeReceipt); + assertAuthorizedFirst(log); + expect(prompts).toEqual([ + `Activate only *.${DOMAIN} at 127.0.0.1 via a new dnsmasq claim and /etc/resolver file? This restarts dnsmasq; existing v4 suffixes remain unchanged.`, + ]); +}); + +test("activate: a failed administrator prompt is authorization-failed, not denied, and changes nothing", async () => { + const ws = await workspace(); + const plan = planFor(ws); + const log: string[] = []; + const { authorize } = recordingAuthorizer({ + action: "activate", + log, + administrator: REJECTED, + }); + const failure = await activateNativeDomainDns({ + dependencies: recordingHost({ + ws, + plan, + authorize, + log, + loopbackAnswer: true, + }), + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(NativeDnsActivationError); + const error = failure as NativeDnsActivationError; + expect(error.phase).toBe("authorization"); + expect(error.rollbackUncertain).toBe(false); + expect(error.originalCause).toBeInstanceOf( + NativeDnsAdministratorAuthorizationError + ); + expect(error.originalCause).toMatchObject({ + outcome: "authorization-failed", + failure: "rejected", + }); + expect(error.message).toBe( + 'Native DNS activation failed during authorization: macOS administrator authorization did not complete (sudo -v did not accept the administrator password (exit 1)); nothing changed. Run "sudo -v" in this terminal first, then retry.' + ); + expect(log).toEqual(["confirm", SUDO_V]); + await expect(readFile(ws.receiptPath)).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(readFile(plan.dnsmasqPath)).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +test("activate: a declined confirmation is denied and never reaches sudo", async () => { + const ws = await workspace(); + const plan = planFor(ws); + const log: string[] = []; + const { authorize } = recordingAuthorizer({ + action: "activate", + log, + accept: false, + }); + await expect( + activateNativeDomainDns({ + dependencies: recordingHost({ + ws, + plan, + authorize, + log, + loopbackAnswer: true, + }), + }) + ).rejects.toMatchObject({ + phase: "authorization", + message: + "Native DNS activation failed during authorization: Native DNS activation was denied", + }); + expect(log).toEqual(["confirm"]); +}); + +test("deactivate: native administrator authorization precedes every privileged and file effect", async () => { + const ws = await workspace(); + const plan = await activeClaim(ws); + const log: string[] = []; + const { authorize, prompts } = recordingAuthorizer({ + action: "deactivate", + log, + }); + const inactive = await deactivateNativeDomainDns({ + dependencies: recordingHost({ + ws, + plan, + authorize, + log, + loopbackAnswer: false, + }), + receiptPath: ws.receiptPath, + }); + expect(inactive).toEqual(plan.inactiveReceipt); + assertAuthorizedFirst(log); + expect(log).toContain("sudo -n resolver remove"); + expect(prompts).toEqual([ + `Deactivate only the owned *.${DOMAIN} DNS claim? This restarts dnsmasq; the existing v4 parent remains unchanged.`, + ]); +}); + +test("deactivate: a failed administrator prompt is authorization-failed, not denied, and changes nothing", async () => { + const ws = await workspace(); + const plan = await activeClaim(ws); + const log: string[] = []; + const { authorize } = recordingAuthorizer({ + action: "deactivate", + log, + administrator: { status: "failed", failure: "timed-out", exitCode: 124 }, + }); + const failure = await deactivateNativeDomainDns({ + dependencies: recordingHost({ + ws, + plan, + authorize, + log, + loopbackAnswer: false, + }), + receiptPath: ws.receiptPath, + }).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(NativeDnsDeactivationError); + const error = failure as NativeDnsDeactivationError; + expect(error.phase).toBe("authorization"); + expect(error.originalCause).toMatchObject({ + outcome: "authorization-failed", + failure: "timed-out", + }); + expect(error.message).not.toContain("denied"); + expect(error.message).toContain( + "the macOS administrator prompt timed out after 120 seconds" + ); + expect(log).toEqual(["confirm", SUDO_V]); + expect(await readFile(ws.receiptPath, "utf8")).toBe( + `${JSON.stringify(plan.activeReceipt)}\n` + ); + expect(await readFile(plan.resolverPath, "utf8")).toBe(plan.resolverContent); + expect(await readFile(plan.dnsmasqPath, "utf8")).toBe(plan.dnsmasqContent); +}); + +test("deactivate: a declined confirmation is denied and never reaches sudo", async () => { + const ws = await workspace(); + const plan = await activeClaim(ws); + const log: string[] = []; + const { authorize } = recordingAuthorizer({ + action: "deactivate", + log, + accept: false, + }); + await expect( + deactivateNativeDomainDns({ + dependencies: recordingHost({ + ws, + plan, + authorize, + log, + loopbackAnswer: false, + }), + receiptPath: ws.receiptPath, + }) + ).rejects.toMatchObject({ + phase: "authorization", + message: + "Native DNS deactivation failed during authorization: Native DNS deactivation was denied", + }); + expect(log).toEqual(["confirm"]); + expect(await readFile(plan.resolverPath, "utf8")).toBe(plan.resolverContent); +}); + +test("without an interactive terminal neither action prompts nor reaches sudo", async () => { + for (const action of ["activate", "deactivate"] as const) { + const log: string[] = []; + const { authorize, prompts } = recordingAuthorizer({ + action, + log, + interactive: false, + }); + expect(await authorize(planFor(await workspace()))).toBe(false); + expect(log).toEqual([]); + expect(prompts).toEqual([]); + } +}); + +test("every administrator failure class throws instead of returning false", async () => { + const failures = [ + { status: "failed", failure: "not-foreground", exitCode: null }, + { status: "failed", failure: "timed-out", exitCode: 124 }, + { status: "failed", failure: "interrupted", exitCode: 130 }, + { status: "failed", failure: "rejected", exitCode: 1 }, + ] as const; + for (const result of failures) { + await expect( + requireNativeAdministratorAuthorization({ + requestAdministrator: async () => result, + }) + ).rejects.toMatchObject({ + name: "NativeDnsAdministratorAuthorizationError", + outcome: "authorization-failed", + failure: result.failure, + }); + } + expect( + await requireNativeAdministratorAuthorization({ + requestAdministrator: async () => AUTHORIZED, + }) + ).toBe(true); +}); From 62a9d124d1146eb62bdcfac6434820eddc352794 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 22:34:48 -0400 Subject: [PATCH 2/9] test(dns): guard the global dns handlers' authorizer wiring and its defaults tests/global-dns-authorizer-wiring.test.ts checks, on the TypeScript syntax tree of src/commands/global.ts, that handleGlobalDnsActivate and handleGlobalDnsDeactivate each build exactly one authorizer with createNativeDnsAuthorizer({ action: , domain }), pass it to createNativeDnsHostDependencies, and run their flow with those dependencies. The only other authorizer allowed is the decline-only `async () => false` on the deactivate verify-only path. tests/native-domain-dns-authorization.test.ts now also covers the production defaults: the default interactivity check requires a stdin TTY before confirming, and the default confirmation declines an explicitly non-interactive run before sudo. The administrator prompt stays a fake, so no test reaches real sudo. Co-Authored-By: Claude Opus 5.5 --- tests/global-dns-authorizer-wiring.test.ts | 232 ++++++++++++++++++ tests/native-domain-dns-authorization.test.ts | 92 +++++++ 2 files changed, 324 insertions(+) create mode 100644 tests/global-dns-authorizer-wiring.test.ts diff --git a/tests/global-dns-authorizer-wiring.test.ts b/tests/global-dns-authorizer-wiring.test.ts new file mode 100644 index 000000000..e5dfafc6e --- /dev/null +++ b/tests/global-dns-authorizer-wiring.test.ts @@ -0,0 +1,232 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; + +/* + * Source guard for `hack global dns activate|deactivate`. The handlers need a + * real macOS host, Homebrew dnsmasq and sudo, so no test runs them. This guard + * checks on the TypeScript syntax tree of src/commands/global.ts that each + * handler installs createNativeDnsAuthorizer with its own action, and that the + * flow it runs receives exactly that authorizer. The authorizer and the flows + * are proven behaviorally in tests/native-domain-dns-authorization.test.ts. + * + * Limits: this is a syntax check of one file, not data flow. It trusts the + * imported createNativeDnsAuthorizer and the flow functions by name. + */ + +const GLOBAL_SOURCE = join( + import.meta.dir, + "..", + "src", + "commands", + "global.ts" +); + +const HANDLERS = [ + { + handler: "handleGlobalDnsActivate", + action: "activate", + flow: "activateNativeDomainDns", + }, + { + handler: "handleGlobalDnsDeactivate", + action: "deactivate", + flow: "deactivateNativeDomainDns", + }, +] as const; + +function parseGlobalSource(): ts.SourceFile { + return ts.createSourceFile( + GLOBAL_SOURCE, + readFileSync(GLOBAL_SOURCE, "utf8"), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); +} + +function findFunction( + source: ts.SourceFile, + name: string +): ts.FunctionDeclaration { + const found = source.statements.find( + (statement): statement is ts.FunctionDeclaration => + ts.isFunctionDeclaration(statement) && statement.name?.text === name + ); + if (!found?.body) { + throw new Error(`${name} not found in src/commands/global.ts`); + } + return found; +} + +function collect( + root: ts.Node, + match: (node: ts.Node) => node is T +): T[] { + const out: T[] = []; + const visit = (node: ts.Node): void => { + if (match(node)) { + out.push(node); + } + ts.forEachChild(node, visit); + }; + visit(root); + return out; +} + +function callsNamed(root: ts.Node, name: string): ts.CallExpression[] { + return collect( + root, + (node): node is ts.CallExpression => + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === name + ); +} + +function objectArgument(call: ts.CallExpression): ts.ObjectLiteralExpression { + const [arg] = call.arguments; + if (!(arg && ts.isObjectLiteralExpression(arg))) { + throw new Error("expected an object literal argument"); + } + return arg; +} + +/** The value of `name` in an object literal; shorthand yields the identifier. */ +function propertyValue( + object: ts.ObjectLiteralExpression, + name: string +): ts.Expression | undefined { + for (const property of object.properties) { + if (ts.isShorthandPropertyAssignment(property)) { + if (property.name.text === name) { + return property.name; + } + } else if ( + ts.isPropertyAssignment(property) && + ts.isIdentifier(property.name) && + property.name.text === name + ) { + return property.initializer; + } + } + return; +} + +/** The `const = ` binding a call result is stored in. */ +function constBindingName(call: ts.CallExpression): string | undefined { + const parent = call.parent; + if ( + ts.isVariableDeclaration(parent) && + parent.initializer === call && + ts.isIdentifier(parent.name) && + ts.isVariableDeclarationList(parent.parent) && + (parent.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + return parent.name.text; + } + return; +} + +function isIdentifierNamed( + node: ts.Node | undefined, + name: string | undefined +): boolean { + return ( + node !== undefined && + name !== undefined && + ts.isIdentifier(node) && + node.text === name + ); +} + +/** `async () => false`: the only other authorizer a handler may pass. */ +function isDecliningAuthorizer(node: ts.Node | undefined): boolean { + return ( + node !== undefined && + ts.isArrowFunction(node) && + node.parameters.length === 0 && + node.body.kind === ts.SyntaxKind.FalseKeyword + ); +} + +interface WiringReport { + readonly authorizerActions: readonly string[]; + readonly authorizerDomains: readonly string[]; + readonly authorizerBinding: string | undefined; + readonly flowCalls: number; + readonly flowUsesAuthorizer: boolean; + readonly otherAuthorizers: readonly string[]; +} + +function inspectHandler( + source: ts.SourceFile, + spec: (typeof HANDLERS)[number] +): WiringReport { + const body = findFunction(source, spec.handler).body as ts.Block; + const factoryCalls = callsNamed(body, "createNativeDnsAuthorizer"); + const authorizerActions = factoryCalls.map((call) => { + const action = propertyValue(objectArgument(call), "action"); + return action && ts.isStringLiteral(action) ? action.text : ""; + }); + const authorizerDomains = factoryCalls.map((call) => { + const domain = propertyValue(objectArgument(call), "domain"); + return domain?.getText(source) ?? ""; + }); + const [factoryCall] = factoryCalls; + const authorizerBinding = + factoryCalls.length === 1 && factoryCall + ? constBindingName(factoryCall) + : undefined; + + const hostCalls = callsNamed(body, "createNativeDnsHostDependencies"); + const otherAuthorizers: string[] = []; + const hostBindingsWithAuthorizer = new Set(); + for (const call of hostCalls) { + const authorize = propertyValue(objectArgument(call), "authorize"); + if (isIdentifierNamed(authorize, authorizerBinding)) { + const binding = constBindingName(call); + if (binding) { + hostBindingsWithAuthorizer.add(binding); + } + } else if (!isDecliningAuthorizer(authorize)) { + otherAuthorizers.push(authorize?.getText(source) ?? ""); + } + } + + const flowCalls = callsNamed(body, spec.flow); + const flowUsesAuthorizer = + flowCalls.length > 0 && + flowCalls.every((call) => { + const deps = propertyValue(objectArgument(call), "dependencies"); + return ( + deps !== undefined && + ts.isIdentifier(deps) && + hostBindingsWithAuthorizer.has(deps.text) + ); + }); + + return { + authorizerActions, + authorizerDomains, + authorizerBinding, + flowCalls: flowCalls.length, + flowUsesAuthorizer, + otherAuthorizers, + }; +} + +for (const spec of HANDLERS) { + test(`${spec.handler} runs ${spec.flow} with the native ${spec.action} authorizer`, () => { + const report = inspectHandler(parseGlobalSource(), spec); + expect(report).toEqual({ + authorizerActions: [spec.action], + authorizerDomains: ["domain"], + authorizerBinding: expect.any(String), + flowCalls: 1, + flowUsesAuthorizer: true, + otherAuthorizers: [], + }); + }); +} diff --git a/tests/native-domain-dns-authorization.test.ts b/tests/native-domain-dns-authorization.test.ts index 644a3fc29..d4c974d3c 100644 --- a/tests/native-domain-dns-authorization.test.ts +++ b/tests/native-domain-dns-authorization.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AdministratorAuthorization } from "../src/lib/admin-authorization.ts"; +import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; import { activateNativeDomainDns, NativeDnsActivationError, @@ -457,3 +458,94 @@ test("every administrator failure class throws instead of returning false", asyn }) ).toBe(true); }); + +/* + * Production defaults. Only `isInteractive` and `confirm` fall back to the + * defaults here; the administrator prompt stays a fake, so no test reaches + * the real `sudo -v`. + */ + +async function withStdinTty( + isTTY: boolean | undefined, + run: () => Promise +): Promise { + const saved = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); + Object.defineProperty(process.stdin, "isTTY", { + value: isTTY, + configurable: true, + writable: true, + }); + try { + return await run(); + } finally { + if (saved) { + Object.defineProperty(process.stdin, "isTTY", saved); + } else { + Reflect.deleteProperty(process.stdin, "isTTY"); + } + } +} + +async function withExplicitNonInteractive(run: () => Promise) { + const saved = process.env.HACK_NO_INTERACTIVE; + process.env.HACK_NO_INTERACTIVE = "1"; + resetNoInteractiveFlagForTests(); + try { + return await run(); + } finally { + if (saved === undefined) { + Reflect.deleteProperty(process.env, "HACK_NO_INTERACTIVE"); + } else { + process.env.HACK_NO_INTERACTIVE = saved; + } + } +} + +test("default interactivity check requires a stdin TTY before confirming", async () => { + const plan = planFor(await workspace()); + for (const action of ["activate", "deactivate"] as const) { + for (const isTTY of [undefined, false, true] as const) { + const log: string[] = []; + const authorize = createNativeDnsAuthorizer({ + action, + domain: DOMAIN, + dependencies: { + confirm: async () => { + log.push("confirm"); + return false; + }, + requestAdministrator: async () => { + log.push(SUDO_V); + return AUTHORIZED; + }, + }, + }); + expect(await withStdinTty(isTTY, async () => await authorize(plan))).toBe( + false + ); + expect(log).toEqual(isTTY === true ? ["confirm"] : []); + } + } +}); + +test("default confirmation declines an explicitly non-interactive run before sudo", async () => { + const plan = planFor(await workspace()); + for (const action of ["activate", "deactivate"] as const) { + const log: string[] = []; + const authorize = createNativeDnsAuthorizer({ + action, + domain: DOMAIN, + dependencies: { + isInteractive: () => true, + requestAdministrator: async () => { + log.push(SUDO_V); + return AUTHORIZED; + }, + }, + }); + expect( + await withExplicitNonInteractive(async () => await authorize(plan)) + ).toBe(false); + expect(log).toEqual([]); + } +}); From 68e898d2f84e384e3c9056bc1d3905ed52b429b4 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 22:40:28 -0400 Subject: [PATCH 3/9] fix(agents): build every onboarding prompt from one discovery-first builder `hack init --with`, `hack agent onboard`, `hack agent init` and the `hack-init` MCP prompt now call one builder in src/agents/onboarding-prompt.ts. It runs the corrected repo inventory (bun.lock, nested workspace lockfiles, non-JS runtimes, runtime pins, explicit discovery limits) and renders it before the phases. Each prompt is exactly frame.before + core + frame.after; the core is byte-identical for the same repo and only a fixed, value-free framing line differs. Mode, project name and dev host now follow repo state, so the init handoff always adopts the `.hack/` it runs after; its framing says whether this run scaffolded it. The MCP prompt takes an optional repoRoot; without it nothing is scanned and the core says no inventory ran. MCP inventory failures return closed, value-free errors. HACK-1137 Co-Authored-By: Claude Opus 5.5 --- docs/agent-guidance.md | 4 +- docs/guides/agent-first-setup.md | 30 +- src/agents/init-assistant.ts | 36 +- src/agents/onboarding-prompt.ts | 235 +++++++++++-- src/commands/agent.ts | 44 +-- src/commands/project.ts | 47 +-- src/mcp/server.ts | 99 ++++-- tests/init-assistant.test.ts | 8 +- tests/init-with-command.test.ts | 11 +- tests/onboarding-entry-parity.test.ts | 474 ++++++++++++++++++++++++++ tests/onboarding-prompt.test.ts | 33 +- tests/setup.test.ts | 11 +- 12 files changed, 871 insertions(+), 161 deletions(-) create mode 100644 tests/onboarding-entry-parity.test.ts diff --git a/docs/agent-guidance.md b/docs/agent-guidance.md index aaf6d11b0..9e87f3b16 100644 --- a/docs/agent-guidance.md +++ b/docs/agent-guidance.md @@ -17,9 +17,9 @@ run only `hack agent prime`. See [model-policy ownership](reference/agent-client | `apps/macos/AGENTS.md` | Explicit maintenance of the unsupported native app | | `.ai/skills/` | Contributor quality, verification, work units, performance and TLA+ workflows | | `src/agents/instruction-source.ts` | Project AGENTS/CLAUDE snippets, standalone/shared Hack skill, Cursor rules, session primer, native plugin skill/rule | -| `src/agents/onboarding-prompt.ts` | `hack agent onboard`, init handoff, and MCP onboarding prompt | +| `src/agents/onboarding-prompt.ts` | The one onboarding builder: `hack agent onboard`, `hack agent init`, init handoff, and MCP onboarding prompt (same core, per-entry framing only) | | `src/agents/hack-init-skill.ts` | Thin Claude/Codex/plugin adapter that fetches onboarding guidance | -| `src/agents/init-patterns.ts`, `init-assistant.ts` | Optional init inventory and pattern hints | +| `src/agents/init-patterns.ts`, `init-assistant.ts` | Repo inventory embedded by the onboarding builder, and pattern hints | | `src/agents/install-skill.ts`, `remote-bootstrap-skill.ts` | Plugin installation/channel selection and fresh-host/cloud setup skills | | `src/agents/focused-skills.ts` | Plugin `hack-branches`, `hack-env` and `hack-diagnose` skills, composed from canonical section ids, and the plugin `hack-cli` routing description | | `src/agents/error-code-reference.ts` | `hack-diagnose` error-code reference, rendered from the exported `HACK_ERROR_CODES` registry in `src/lib/cli-result.ts` | diff --git a/docs/guides/agent-first-setup.md b/docs/guides/agent-first-setup.md index cba4ead33..54602c57a 100644 --- a/docs/guides/agent-first-setup.md +++ b/docs/guides/agent-first-setup.md @@ -5,6 +5,13 @@ existing setup — instead of doing it by hand. One canonical onboarding prompt every entry point below; the content lives in the CLI, so it is always current for your installed version. +Every entry point calls the same builder with the same repo inventory. For the same +repo, `hack init --with`, `hack agent onboard`, `hack agent init`, and the +`hack-init` MCP prompt (with `repoRoot`) produce byte-identical prompt content; each +adds at most a fixed framing line. The mode follows the repo: `existing-project` +when `.hack/` exists (project name and dev host come from its config), +`new-project` otherwise. + ## Entry points (same prompt, four ways in) | You have | Use | @@ -25,8 +32,9 @@ hack init --auto --with claude # or codex, or both onboarding prompt (`claude ""` / `codex ""`). - If the agent CLI is not on PATH, the prompt is printed with copy-paste instructions instead. -- If `.hack/` already exists, init is skipped and the handoff proceeds in - existing-project mode. +- The handoff always runs after `.hack/` exists, so the prompt is in + existing-project mode. A framing line says whether this run scaffolded `.hack/` + or found it already present and skipped init. - With `--no-interactive` (or `HACK_NO_INTERACTIVE=1`, or no TTY) nothing is spawned — the prompt is always printed. @@ -37,8 +45,15 @@ hack agent onboard # prints the onboarding prompt for this project hack agent onboard | pbcopy # copy it for any agent session ``` -The prompt picks up the project name and dev host from `.hack/hack.config.json` -when present. +The prompt starts with a pre-computed repo inventory and picks up the project name +and dev host from `.hack/hack.config.json` when present. + +The `hack-init` MCP prompt takes an optional `repoRoot` argument (relative paths +resolve against the MCP server's working directory). With `repoRoot` it returns the +same content as `hack agent onboard` for that repo and ignores the `mode`, +`projectName`, and `devHost` arguments. Without it the server scans nothing, uses +those arguments (default `existing-project`), and the prompt states that no +inventory was run. ## Agent-side skill: `/hack-init` @@ -117,10 +132,11 @@ The ground rules tell the agent not to create or modify `AGENTS.md`, `CLAUDE.md` rule files, or repository skill copies unless asked. Onboarding configures the runtime; it does not install agent instructions. The agent also reports the discovery limits it hit: a missing agent client (the prompt was printed instead of -launched), a non-interactive run, or the no-shell `hack-init` MCP prompt, which -defaults to existing-project mode without a repo inventory. +launched), a non-interactive run, the inventory's remaining discovery limitations, +or a `hack-init` MCP prompt request without `repoRoot`, which defaults to +existing-project mode without a repo inventory. -`hack agent init` adds a pre-computed inventory before the same prompt. It reads +The pre-computed inventory comes before the phases in every entry point. It reads file names and manifest keys only, never env-file contents, lockfile contents or pin values. It ends with a list of discovery limitations, such as conflicting package managers or non-JS services whose entrypoints were not inferred. diff --git a/src/agents/init-assistant.ts b/src/agents/init-assistant.ts index 0b9c5a515..d70f7c42e 100644 --- a/src/agents/init-assistant.ts +++ b/src/agents/init-assistant.ts @@ -6,7 +6,6 @@ import { pathExists, readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import type { ProjectContext } from "../lib/project.ts"; import { findProjectContext } from "../lib/project.ts"; -import { renderOnboardingPrompt } from "./onboarding-prompt.ts"; export type ComposeSignal = { readonly path: string; @@ -159,28 +158,19 @@ export async function buildInitAssistantReport(opts: { } /** - * Render the init assistant prompt based on repo signals. + * Render the pre-computed repo inventory section of the onboarding prompt. * - * The dynamic repo-signal report is rendered here; the canonical onboarding - * guidance (phases, deps/ops container patterns, verification loop) comes - * from the shared onboarding prompt module so content never forks between - * `hack agent init`, `hack agent onboard`, and `hack init --with`. + * This is the only renderer of {@link InitAssistantReport}. Every onboarding + * entry point (`hack init --with`, `hack agent onboard`, `hack agent init`, and + * the `hack-init` MCP prompt) reaches it through the shared core renderer in + * `onboarding-prompt.ts`, so the inventory text cannot fork between them. + * The returned lines carry no heading and no trailing blank line. */ -export function renderInitAssistantPrompt(opts: { +export function renderInitAssistantInventory(opts: { readonly report: InitAssistantReport; -}): string { +}): string[] { const lines: string[] = []; - lines.push("# hack agent init"); - lines.push(""); - lines.push( - [ - "You are helping a user bootstrap hack in this repo.", - "Prefer the Hack CLI and infer routine details from the repo; ask only when missing information materially affects correctness or scope.", - "A pre-computed repo inventory follows — confirm and extend it while working through the onboarding phases below.", - ].join(" ") - ); - lines.push(""); lines.push("Repo context:"); lines.push(`- Root: ${opts.report.repoRoot}`); lines.push(`- Hack config: ${formatHackContext({ report: opts.report })}`); @@ -267,16 +257,8 @@ export function renderInitAssistantPrompt(opts: { for (const question of buildSuggestedQuestions({ report: opts.report })) { lines.push(`- ${question}`); } - lines.push(""); - lines.push( - renderOnboardingPrompt({ - mode: opts.report.projectContext ? "existing-project" : "new-project", - }) - ); - lines.push("Need dependency/ops patterns? Run `hack agent patterns`."); - lines.push(""); - return lines.join("\n"); + return lines; } const DOCKERFILE_PATTERNS = ["**/Dockerfile", "**/Dockerfile.*"] as const; diff --git a/src/agents/onboarding-prompt.ts b/src/agents/onboarding-prompt.ts index bee6260a4..8eef76028 100644 --- a/src/agents/onboarding-prompt.ts +++ b/src/agents/onboarding-prompt.ts @@ -1,15 +1,36 @@ +import { + findProjectContext, + findRepoRootForInit, + readProjectConfig, + readProjectDevHost, +} from "../lib/project.ts"; +import { + buildInitAssistantReport, + type InitAssistantReport, + renderInitAssistantInventory, +} from "./init-assistant.ts"; import { INSTRUCTION_SECTIONS } from "./instruction-source.ts"; /** * Single source of truth for the agent-assisted onboarding prompt. * - * Consumers (keep this list current — the content must never fork): - * - `hack init --with claude|codex` (CLI handoff for new repos), - * - `hack agent onboard` (CLI print for existing projects), - * - the `/hack-init` agent-side skill (thin pointer shipped by the default - * `hack` plugin, or exported by opt-in standalone `hack setup claude|codex`), - * - the `hack-init` MCP prompt (no-shell clients), - * - the copy-paste bootstrap block in `docs/guides/agent-first-setup.md`. + * Every entry point builds its prompt here, from one repo inventory, so the + * content can never fork (keep this list current): + * - `hack init --with claude|codex` (CLI handoff after init), + * - `hack agent onboard` (CLI print), + * - `hack agent init` (CLI print or client handoff), + * - the `hack-init` MCP prompt (no-shell clients). + * + * Contract: an entry point calls {@link buildOnboardingPrompt} (or, only for + * the MCP prompt without a `repoRoot`, {@link onboardingContextWithoutInventory} + * plus {@link renderOnboardingPrompt}). The rendered prompt is exactly + * `frame.before + core + frame.after`: the core comes from + * {@link renderOnboardingCore} and is byte-identical for every entry point on + * the same repo; {@link renderOnboardingFrame} owns the only per-entry-point + * text, and that text never repeats repo values. + * + * The `/hack-init` agent-side skill and the copy-paste bootstrap block in + * `docs/guides/agent-first-setup.md` are thin pointers to these entry points. */ export type OnboardingMode = "new-project" | "existing-project"; @@ -42,43 +63,178 @@ const DEPS_CONTAINER_SNIPPET = [ " node_modules:", ].join("\n"); +/** Why no pre-computed inventory is in the prompt. Closed set; value-free. */ +export type OnboardingInventoryGap = "no-repo-root"; + +/** The repo inventory every entry point embeds, or the closed reason it is absent. */ +export type OnboardingInventory = + | { readonly status: "collected"; readonly report: InitAssistantReport } + | { readonly status: "not-run"; readonly reason: OnboardingInventoryGap }; + +/** Everything the core prompt depends on. Nothing else may vary it. */ +export type OnboardingContext = { + readonly mode: OnboardingMode; + readonly projectName?: string; + readonly devHost?: string; + readonly inventory: OnboardingInventory; +}; + /** - * Render the canonical project-onboarding prompt for a coding agent. + * The entry point that asked for the prompt. It selects the framing only; + * it never changes the core. * - * The prompt walks an agent through inventorying a repo, standing hack up - * (or resuming/adopting an existing `.hack/` setup), platform nuances - * (locked deps installs, cache labels, ops containers), the running-things - * decision guide, and a verification loop that includes data persistence. - * Ground rules forbid injecting agent instruction files and require the - * agent to report discovery limits (missing client, non-interactive run, - * no-shell MCP prompt). + * `init-with.scaffold` says whether this `hack init` run wrote `.hack/` + * (`created`) or found it already present and skipped init (`existing`). + */ +export type OnboardingEntryPoint = + | { readonly kind: "init-with"; readonly scaffold: "created" | "existing" } + | { readonly kind: "agent-onboard" } + | { readonly kind: "agent-init" } + | { readonly kind: "mcp-hack-init" }; + +/** Per-entry-point text placed around the core. */ +export type OnboardingFrame = { + readonly before: string; + readonly after: string; +}; + +const INVENTORY_GAP_TEXT: Readonly> = { + "no-repo-root": "this `hack-init` MCP prompt request named no `repoRoot`", +}; + +/** + * Inventory the repo at or above `startDir` and derive the onboarding context. * - * @param opts.mode - `new-project` when `.hack/` does not exist yet; - * `existing-project` when hack is already configured and should be - * adopted/refined. - * @param opts.projectName - Known project slug (from config), when available. - * @param opts.devHost - Known dev host (from config), when available. + * The repo root is the enclosing hack project root when one exists, otherwise + * the `hack init` repo root. Mode follows repo state alone: `existing-project` + * when a `.hack/` project is found (project name and dev host then come from + * its config), `new-project` otherwise. Errors from project discovery (for + * example a native project marker) propagate unchanged. */ -export function renderOnboardingPrompt(opts: { +export async function collectOnboardingContext(opts: { + readonly startDir: string; +}): Promise { + const project = await findProjectContext(opts.startDir); + const repoRoot = project + ? project.projectRoot + : await findRepoRootForInit(opts.startDir); + const report = await buildInitAssistantReport({ repoRoot }); + const inventory: OnboardingInventory = { status: "collected", report }; + if (!report.projectContext) { + return { mode: "new-project", inventory }; + } + + const config = await readProjectConfig(report.projectContext); + const devHost = await readProjectDevHost(report.projectContext); + return { + mode: "existing-project", + ...(config.name ? { projectName: config.name } : {}), + ...(devHost ? { devHost } : {}), + inventory, + }; +} + +/** + * Context for a request that names no repo (the `hack-init` MCP prompt + * without `repoRoot`). The core then states that no inventory was run. + */ +export function onboardingContextWithoutInventory(opts: { readonly mode: OnboardingMode; readonly projectName?: string; readonly devHost?: string; + readonly reason: OnboardingInventoryGap; +}): OnboardingContext { + return { + mode: opts.mode, + ...(opts.projectName ? { projectName: opts.projectName } : {}), + ...(opts.devHost ? { devHost: opts.devHost } : {}), + inventory: { status: "not-run", reason: opts.reason }, + }; +} + +/** + * Collect the repo inventory and render the framed prompt for one entry point. + * This is the builder every CLI entry point calls. + */ +export async function buildOnboardingPrompt(opts: { + readonly startDir: string; + readonly entryPoint: OnboardingEntryPoint; +}): Promise { + const context = await collectOnboardingContext({ startDir: opts.startDir }); + return renderOnboardingPrompt({ context, entryPoint: opts.entryPoint }); +} + +/** Render `frame.before + core + frame.after` for one entry point. */ +export function renderOnboardingPrompt(opts: { + readonly context: OnboardingContext; + readonly entryPoint: OnboardingEntryPoint; +}): string { + const frame = renderOnboardingFrame({ entryPoint: opts.entryPoint }); + return `${frame.before}${renderOnboardingCore({ context: opts.context })}${frame.after}`; +} + +/** + * The only per-entry-point text. It is fixed per entry point and never + * repeats repo values, so it cannot carry inventory drift. + */ +export function renderOnboardingFrame(opts: { + readonly entryPoint: OnboardingEntryPoint; +}): OnboardingFrame { + const entryPoint = opts.entryPoint; + switch (entryPoint.kind) { + case "init-with": + return { + before: + entryPoint.scaffold === "created" + ? "Handoff from `hack init --with`: this run scaffolded `.hack/`. Treat the generated config as a starting point and refine it instead of re-running `hack init`.\n\n" + : "Handoff from `hack init --with`: `.hack/` already existed, so init was skipped and nothing was scaffolded.\n\n", + after: "", + }; + case "agent-init": + return { + before: "", + after: "Need dependency/ops patterns? Run `hack agent patterns`.\n", + }; + case "agent-onboard": + case "mcp-hack-init": + return { before: "", after: "" }; + default: + return assertNeverEntryPoint(entryPoint); + } +} + +/** + * Render the canonical, entry-point-independent onboarding prompt. + * + * Discovery first: the pre-computed repo inventory (or the closed reason it + * is absent) precedes the phases, which walk an agent through confirming the + * inventory, standing hack up (or resuming/adopting an existing `.hack/` + * setup), platform nuances (locked deps installs, cache labels, ops + * containers), the running-things decision guide, and a verification loop + * that includes data persistence. Ground rules forbid injecting agent + * instruction files and require the agent to report discovery limits. + */ +export function renderOnboardingCore(opts: { + readonly context: OnboardingContext; }): string { + const { context } = opts; const lines: string[] = []; - lines.push(renderHeader(opts)); + lines.push(renderHeader(context)); lines.push(""); - lines.push(...renderIntro(opts)); + lines.push(...renderIntro(context)); + lines.push(""); + lines.push(...renderInventorySection(context.inventory)); lines.push(""); lines.push(...renderInventoryPhase()); lines.push(""); - lines.push(...renderSetupPhase(opts)); + lines.push(...renderSetupPhase(context)); lines.push(""); lines.push(...renderPlatformPhase()); lines.push(""); lines.push(...renderRunningThingsPhase()); lines.push(""); - lines.push(...renderVerifyPhase(opts)); + lines.push(...renderVerifyPhase(context)); lines.push(""); lines.push(...renderGroundRules()); lines.push(""); @@ -86,6 +242,26 @@ export function renderOnboardingPrompt(opts: { return lines.join("\n"); } +function assertNeverEntryPoint(value: never): never { + throw new Error(`Unknown onboarding entry point: ${String(value)}`); +} + +function renderInventorySection(inventory: OnboardingInventory): string[] { + const heading = ["## Repo inventory (pre-computed by Hack)", ""]; + if (inventory.status === "not-run") { + return [ + ...heading, + `- Not run: ${INVENTORY_GAP_TEXT[inventory.reason]}. Inventory the repo yourself in Phase 1 and report that no pre-computed inventory was available.`, + ]; + } + return [ + ...heading, + "Hack read file names and manifest keys to build this inventory. Confirm and extend it in Phase 1; an absent signal is not an absent requirement.", + "", + ...renderInitAssistantInventory({ report: inventory.report }), + ]; +} + function renderHeader(opts: { readonly mode: OnboardingMode; readonly projectName?: string; @@ -114,6 +290,7 @@ function renderInventoryPhase(): string[] { return [ "## Phase 1 — Inventory the repo", "", + "- Start from the pre-computed inventory above: confirm each detected signal, check every listed discovery limitation yourself, and report the ones that remain. When no inventory was run, do the full inventory below.", "- Read the root package.json (including its `packageManager` and `engines` keys), workspace globs, and lockfiles (`bun.lock` or legacy `bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`) at the root and in each workspace package to identify the package manager and monorepo layout. Lockfiles from more than one package manager, or a nested lockfile inside a workspace package, are a conflict to resolve with the human — do not delete or regenerate a lockfile to pick one.", "- Note non-JS manifests and lockfiles (Cargo.toml/Cargo.lock, go.mod, pyproject.toml/uv.lock/poetry.lock/requirements.txt, Gemfile.lock, mix.exs/mix.lock, *.csproj/*.sln) and runtime pins (`.nvmrc`, `.tool-versions`, `mise.toml`, `engines`, `packageManager`) by name. Find those services' entrypoints, ports, and setup steps from their build files and docs; pinned versions decide the container images and tools you use.", "- Identify runnable services (web, api, workers): dev/start scripts, framework configs, and the ports they listen on.", @@ -128,7 +305,11 @@ function renderSetupPhase(opts: { readonly mode: OnboardingMode; readonly devHost?: string; }): string[] { - const devHostExample = opts.devHost ?? ".hack.local"; + // An existing setup without a configured dev_host must not be described + // with the new-project default host. + const devHostExample = + opts.devHost ?? + (opts.mode === "new-project" ? ".hack.local" : ""); const bootstrap = opts.mode === "new-project" ? "- Scaffold config: run `hack init --auto` (add `--name ` / `--dev-host ` when the defaults are wrong). If `.hack/` already exists, edit the config instead of re-running init." @@ -199,7 +380,7 @@ function renderGroundRules(): string[] { "- Treat `.hack/.internal/**` and `.hack/.branch/**` as generated artifacts — never hand-edit them.", "- Inspect unexpected state with `hack doctor` and apply only authorized repairs. Preserve runtime ownership, native approval, and destructive-cleanup gates.", "- Never create or modify AGENTS.md, CLAUDE.md, editor or agent rule files, or repository copies of agent skills unless the user asks. Onboarding configures the runtime; it does not install agent instructions.", - "- Report discovery limits you hit: the agent client CLI was missing (Hack printed this prompt instead of launching a session), a non-interactive run (nothing was spawned or prompted), or the no-shell `hack-init` MCP prompt (defaults to existing-project mode with no repo inventory — confirm whether `.hack/` exists and inventory the repo yourself).", + "- Report discovery limits you hit: the agent client CLI was missing (Hack printed this prompt instead of launching a session), a non-interactive run (nothing was spawned or prompted), the inventory's remaining discovery limitations, or a `hack-init` MCP prompt request without `repoRoot` (it defaults to existing-project mode with no repo inventory — confirm whether `.hack/` exists and inventory the repo yourself).", "- Summarize what you configured (services, hostnames, env keys added, remaining gaps) when you finish.", ]; } diff --git a/src/commands/agent.ts b/src/commands/agent.ts index be9e465bc..780a6a938 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -2,12 +2,8 @@ import { resolve } from "node:path"; import { isCancel, select } from "@clack/prompts"; -import { - buildInitAssistantReport, - renderInitAssistantPrompt, -} from "../agents/init-assistant.ts"; import { renderAgentInitPatterns } from "../agents/init-patterns.ts"; -import { renderOnboardingPrompt } from "../agents/onboarding-prompt.ts"; +import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { renderAgentPrimer } from "../agents/primer.ts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { @@ -22,12 +18,7 @@ import { } from "../cli/integration-sync.ts"; import { optPath } from "../cli/options.ts"; import { openUrl } from "../lib/os.ts"; -import { - findProjectContext, - findRepoRootForInit, - readProjectConfig, - readProjectDevHost, -} from "../lib/project.ts"; +import { findProjectContext, findRepoRootForInit } from "../lib/project.ts"; import { findExecutableInPath, run } from "../lib/shell.ts"; import { logger } from "../ui/logger.ts"; @@ -149,8 +140,10 @@ async function handleAgentInit({ }): Promise { const startDir = resolveStartDir(ctx, args.options.path); const repoRoot = await findRepoRootForInit(startDir); - const report = await buildInitAssistantReport({ repoRoot }); - const prompt = renderInitAssistantPrompt({ report }); + const prompt = await buildOnboardingPrompt({ + startDir, + entryPoint: { kind: "agent-init" }, + }); const selection = await resolveInitClient({ clientRaw: args.options.client, @@ -179,10 +172,10 @@ async function handleAgentInit({ /** * `hack agent onboard` — print the canonical onboarding prompt. * - * Uses `existing-project` mode (with project name/dev_host from config) when - * a `.hack/` project is found; falls back to `new-project` mode otherwise. - * The same content backs `hack init --with`, the `/hack-init` skill, and the - * `hack-init` MCP prompt. + * The shared builder inventories the repo and picks `existing-project` mode + * (with project name/dev_host from config) when a `.hack/` project is found, + * `new-project` mode otherwise. `hack init --with`, `hack agent init`, and the + * `hack-init` MCP prompt render the same core content. */ async function handleAgentOnboard({ ctx, @@ -192,21 +185,10 @@ async function handleAgentOnboard({ readonly args: OnboardArgs; }): Promise { const startDir = resolveStartDir(ctx, args.options.path); - const projectContext = await findProjectContext(startDir); - - if (!projectContext) { - process.stdout.write(renderOnboardingPrompt({ mode: "new-project" })); - return 0; - } - - const config = await readProjectConfig(projectContext); - const devHost = await readProjectDevHost(projectContext); - process.stdout.write( - renderOnboardingPrompt({ - mode: "existing-project", - projectName: config.name, - devHost: devHost ?? undefined, + await buildOnboardingPrompt({ + startDir, + entryPoint: { kind: "agent-onboard" }, }) ); return 0; diff --git a/src/commands/project.ts b/src/commands/project.ts index 1fe605145..ab283e780 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -33,10 +33,7 @@ import { parseOnboardingWith, runOnboardingHandoff, } from "../agents/onboarding-handoff.ts"; -import { - type OnboardingMode, - renderOnboardingPrompt, -} from "../agents/onboarding-prompt.ts"; +import { buildOnboardingPrompt } from "../agents/onboarding-prompt.ts"; import { composeLogBackend, lokiLogBackend } from "../backends/log-backend.ts"; import { parseNativePreparedBase } from "../backends/native-prepared-base.ts"; import { @@ -2863,16 +2860,6 @@ async function promptInitDevHost(opts: { return devHost.trim(); } -/** A skipped init must describe the existing setup, not the new-project default. */ -async function resolveExistingInitHost(repoRoot: string): Promise { - const project = await findProjectContext(repoRoot); - const configured = project ? await readProjectDevHost(project) : null; - return ( - configured ?? - `${defaultProjectSlugFromPath(repoRoot)}.${DEFAULT_PROJECT_TLD}` - ); -} - function validateInitOauthTld(value: string | undefined): string | undefined { const v = value?.trim().toLowerCase(); if (!v) { @@ -3061,9 +3048,7 @@ async function handleInit({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "existing-project", - projectName: slug, - devHost: await resolveExistingInitHost(repoRoot), + scaffold: "existing", }); } return 0; @@ -3169,9 +3154,7 @@ async function handleInit({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "new-project", - projectName: slug, - devHost, + scaffold: "created", }); } @@ -3234,9 +3217,7 @@ async function handleInitAuto({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "existing-project", - projectName: slug, - devHost: await resolveExistingInitHost(repoRoot), + scaffold: "existing", }); return 0; } @@ -3332,9 +3313,7 @@ async function handleInitAuto({ await runInitOnboardingHandoff({ repoRoot, withValue, - mode: "new-project", - projectName: slug, - devHost, + scaffold: "created", }); } @@ -3373,6 +3352,11 @@ function resolveInitWithOption(opts: { /** * Hand the onboarding prompt to the requested agent CLI(s) after init. * + * The prompt comes from the shared onboarding builder, which inventories the + * repo and reads mode, project name and dev host from the `.hack/` config, so + * the core matches `hack agent onboard`, `hack agent init` and the `hack-init` + * MCP prompt for the same repo. `scaffold` only selects the handoff framing. + * * Never spawns on non-interactive runs (`--no-interactive`, * `HACK_NO_INTERACTIVE`, or no TTY) — the prompt is printed for copy-paste * instead. Init success is never rolled back by handoff problems. @@ -3380,15 +3364,12 @@ function resolveInitWithOption(opts: { async function runInitOnboardingHandoff(opts: { readonly repoRoot: string; readonly withValue: OnboardingWith; - readonly mode: OnboardingMode; - readonly projectName: string; - readonly devHost: string; + readonly scaffold: "created" | "existing"; }): Promise { await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); - const prompt = renderOnboardingPrompt({ - mode: opts.mode, - projectName: opts.projectName, - devHost: opts.devHost, + const prompt = await buildOnboardingPrompt({ + startDir: opts.repoRoot, + entryPoint: { kind: "init-with", scaffold: opts.scaffold }, }); await runOnboardingHandoff({ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index cc6c00ce5..f5df3f362 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,6 +7,7 @@ import { CallToolRequestSchema, type CallToolResult, ErrorCode, + McpError, type ServerNotification, type ServerRequest, type ToolAnnotations, @@ -14,10 +15,12 @@ import { import * as z from "zod/v4"; import pkg from "../../package.json"; import { + buildOnboardingPrompt, type OnboardingMode, + onboardingContextWithoutInventory, renderOnboardingPrompt, } from "../agents/onboarding-prompt.ts"; -import type { HackErrorCode } from "../lib/cli-result.ts"; +import { HackCliError, type HackErrorCode } from "../lib/cli-result.ts"; import { ensureDir, pathExists } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; @@ -310,7 +313,7 @@ export function createMcpServer(opts: { ); registerTools({ server, context }); - registerPrompts({ server }); + registerPrompts({ server, context }); return server; } @@ -350,43 +353,69 @@ const ONBOARDING_MODES: ReadonlySet = new Set([ "existing-project", ]); +const MCP_ONBOARDING_ENTRY_POINT = { kind: "mcp-hack-init" } as const; + /** * Register MCP prompts for no-shell clients. * - * `hack-init` returns the canonical project-onboarding prompt (same content - * as `hack agent onboard`, `hack init --with`, and the `/hack-init` skill). + * `hack-init` returns the canonical project-onboarding prompt from the shared + * builder (same core as `hack agent onboard`, `hack agent init`, and + * `hack init --with`). With `repoRoot` it inventories that repo, and mode, + * project name and dev host come from the repo; without it no filesystem + * scan runs (the server's working directory may be a home or root + * directory) and the core states that no inventory was run. */ -function registerPrompts(opts: { readonly server: McpServer }): void { +function registerPrompts(opts: { + readonly server: McpServer; + readonly context: McpSessionContext; +}): void { opts.server.registerPrompt( "hack-init", { title: "hack project onboarding", description: - "Agent-assisted onboarding prompt: stand up or adopt hack in a project (inventory, compose + env setup, deps/ops container patterns, verification loop).", + "Agent-assisted onboarding prompt: stand up or adopt hack in a project (repo inventory, compose + env setup, deps/ops container patterns, verification loop).", argsSchema: { + repoRoot: z + .string() + .describe( + "Repository root (or a directory inside it) to inventory; relative paths resolve against the server's working directory. When set, mode, projectName and devHost come from the repo and those arguments are ignored." + ) + .optional(), mode: z .string() .describe( - 'Onboarding mode: "new-project" (no .hack/ yet) or "existing-project" (adopt/refine an existing setup). Default: existing-project.' + 'Onboarding mode without repoRoot: "new-project" (no .hack/ yet) or "existing-project" (adopt/refine an existing setup). Default: existing-project.' ) .optional(), - projectName: z.string().describe("Project slug, when known").optional(), + projectName: z + .string() + .describe("Project slug, when known (without repoRoot)") + .optional(), devHost: z .string() - .describe("Project dev host (e.g. myapp.hack), when known") + .describe( + "Project dev host (e.g. myapp.hack.local), when known (without repoRoot)" + ) .optional(), }, }, - ({ mode, projectName, devHost }) => { - const resolvedMode: OnboardingMode = ONBOARDING_MODES.has(mode ?? "") - ? ((mode ?? "existing-project") as OnboardingMode) - : "existing-project"; - - const prompt = renderOnboardingPrompt({ - mode: resolvedMode, - projectName, - devHost, - }); + async ({ repoRoot, mode, projectName, devHost }) => { + const prompt = repoRoot?.trim() + ? await buildMcpOnboardingPrompt({ + startDir: resolve(opts.context.cwd, repoRoot), + }) + : renderOnboardingPrompt({ + context: onboardingContextWithoutInventory({ + mode: ONBOARDING_MODES.has(mode ?? "") + ? (mode as OnboardingMode) + : "existing-project", + ...(projectName ? { projectName } : {}), + ...(devHost ? { devHost } : {}), + reason: "no-repo-root", + }), + entryPoint: MCP_ONBOARDING_ENTRY_POINT, + }); return { messages: [ @@ -400,6 +429,38 @@ function registerPrompts(opts: { readonly server: McpServer }): void { ); } +/** + * Build the `hack-init` prompt for a caller-named repo. Failures map to a + * closed, value-free protocol error: only the Hack error code survives; the + * caller's path, config values and the underlying message are never echoed. + */ +async function buildMcpOnboardingPrompt(opts: { + readonly startDir: string; +}): Promise { + if (!(await pathExists(opts.startDir))) { + const refusal = MCP_REFUSALS.path_not_found; + throw new McpError( + ErrorCode.InvalidParams, + `${refusal.code}: ${refusal.message}` + ); + } + try { + return await buildOnboardingPrompt({ + startDir: opts.startDir, + entryPoint: MCP_ONBOARDING_ENTRY_POINT, + }); + } catch (error) { + const code: HackErrorCode = + error instanceof HackCliError ? error.code : "E_UNEXPECTED"; + throw new McpError( + code === "E_UNEXPECTED" + ? ErrorCode.InternalError + : ErrorCode.InvalidParams, + `${code}: hack-init could not inventory the repo.` + ); + } +} + function registerTools(opts: { readonly server: McpServer; readonly context: McpSessionContext; diff --git a/tests/init-assistant.test.ts b/tests/init-assistant.test.ts index 33d74c87f..640a80b46 100644 --- a/tests/init-assistant.test.ts +++ b/tests/init-assistant.test.ts @@ -6,8 +6,8 @@ import { dirname, join } from "node:path"; import { buildInitAssistantReport, type InitAssistantReport, - renderInitAssistantPrompt, } from "../src/agents/init-assistant.ts"; +import { buildOnboardingPrompt } from "../src/agents/onboarding-prompt.ts"; const ALWAYS_PRESENT_LIMITATION = "Inventory reads file names and manifest keys only; env files, secrets, lockfile contents, and pin values were not read."; @@ -41,7 +41,11 @@ async function inventory(files: Record): Promise<{ }> { const repoRoot = await createRepo(files); const report = await buildInitAssistantReport({ repoRoot }); - return { report, prompt: renderInitAssistantPrompt({ report }) }; + const prompt = await buildOnboardingPrompt({ + startDir: repoRoot, + entryPoint: { kind: "agent-init" }, + }); + return { report, prompt }; } test("bun.lock-only repo is detected as a Bun repo without conflicts", async () => { diff --git a/tests/init-with-command.test.ts b/tests/init-with-command.test.ts index c9b321ef5..5053cd2bd 100644 --- a/tests/init-with-command.test.ts +++ b/tests/init-with-command.test.ts @@ -134,7 +134,13 @@ test("hack init --auto --with claude initializes and prints the prompt when no b expect(result.stdout).toContain("hack onboarding"); expect(result.stdout).toContain("## Phase 1 — Inventory the repo"); expect(result.stdout).toContain("node_modules:/app/node_modules"); - expect(result.stdout).toContain("stand up hack in this repo"); + // The shared builder derives mode from repo state: init just wrote + // `.hack/`, so the core adopts it and only the framing says it is new. + expect(result.stdout).toContain("this run scaffolded `.hack/`"); + expect(result.stdout).toContain("adopt the existing hack setup in this repo"); + expect(result.stdout).toContain("(project: repo)"); + expect(result.stdout).toContain("## Repo inventory (pre-computed by Hack)"); + expect(result.stdout).toContain("- root (demo): dev -> bun run dev.ts"); }); test("hack init --auto --with proceeds to handoff when .hack already exists", async () => { @@ -162,6 +168,9 @@ test("hack init --auto --with proceeds to handoff when .hack already exists", as expect(second.exitCode).toBe(0); expect(`${second.stdout}${second.stderr}`).toContain("already exists"); expect(second.stdout).toContain("adopt the existing hack setup in this repo"); + expect(second.stdout).toContain( + "`.hack/` already existed, so init was skipped" + ); expect(second.stdout).toContain("existing.hack"); expect( (await Bun.file(join(repoRoot, ".hack", "hack.config.json")).json()) diff --git a/tests/onboarding-entry-parity.test.ts b/tests/onboarding-entry-parity.test.ts new file mode 100644 index 000000000..2249d5235 --- /dev/null +++ b/tests/onboarding-entry-parity.test.ts @@ -0,0 +1,474 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { + collectOnboardingContext, + type OnboardingEntryPoint, + onboardingContextWithoutInventory, + renderOnboardingCore, + renderOnboardingFrame, +} from "../src/agents/onboarding-prompt.ts"; +import { createMcpServer } from "../src/mcp/server.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +/** + * HACK-1137: every onboarding entry point (`hack init --with`, + * `hack agent onboard`, `hack agent init`, and the `hack-init` MCP prompt) + * renders `frame.before + core + frame.after`, where the core is byte-identical + * for the same repo. CLI entry points run as source CLI children with a + * private environment; the MCP prompt runs in-process over an in-memory + * transport. No Docker, real HOME, keychain or client config is reached. + */ + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const INIT_PROMPT_START = "----- hack onboarding prompt -----\n"; +const INIT_PROMPT_END = "\n----- end prompt -----\n"; + +const roots: string[] = []; +const clients: Client[] = []; + +afterEach(async () => { + for (const client of clients.splice(0)) { + await client.close().catch(() => undefined); + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +type Fixture = { + readonly root: string; + readonly repo: string; + readonly home: string; + readonly emptyPath: string; +}; + +/** + * A repo that exercises every corrected inventory signal: `bun.lock` at the + * root, nested workspaces with their own (conflicting) lockfile, a non-JS + * runtime two levels down, runtime pins, env files, compose, and DB signals. + */ +async function createFixture(): Promise { + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-onboarding-parity-")) + ); + roots.push(root); + const repo = join(root, "repo"); + const files: Record = { + "package.json": JSON.stringify( + { + name: "parity", + private: true, + workspaces: ["apps/*", "packages/*"], + packageManager: "bun@1.3.9", + engines: { node: ">=22" }, + scripts: { "db:migrate": "prisma migrate dev" }, + }, + null, + 2 + ), + "bun.lock": "{}\n", + ".nvmrc": "22\n", + ".env.example": "DATABASE_URL=\n", + "docker-compose.yml": "services:\n db:\n image: postgres:16\n", + "apps/web/package.json": JSON.stringify({ + name: "web", + scripts: { dev: "vite --port 5173" }, + }), + "apps/legacy/package.json": JSON.stringify({ + name: "legacy", + scripts: { start: "node server.js" }, + }), + "apps/legacy/package-lock.json": "{}\n", + "packages/ui/package.json": JSON.stringify({ name: "ui" }), + "services/worker/Cargo.toml": '[package]\nname = "worker"\n', + "prisma/schema.prisma": "datasource db {}\n", + }; + for (const [rel, content] of Object.entries(files)) { + const abs = join(repo, rel); + await mkdir(dirname(abs), { recursive: true }); + await Bun.write(abs, content); + } + const emptyPath = join(root, "empty-path"); + await mkdir(emptyPath, { recursive: true }); + return { root, repo, home: join(root, "home"), emptyPath }; +} + +async function runCli( + fixture: Fixture, + args: readonly string[] +): Promise { + const child = Bun.spawn([process.execPath, "index.ts", ...args], { + cwd: REPO_ROOT, + env: privateCliEnv({ + root: fixture.root, + home: fixture.home, + env: { + HACK_NO_INTERACTIVE: "1", + // No agent client binary can be found or launched. + PATH: fixture.emptyPath, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + return stdout; +} + +async function connectMcp(cwd: string): Promise { + const server = createMcpServer({ cwd, env: { HOME: cwd } }); + const client = new Client({ name: "onboarding-parity", version: "0.0.0" }); + clients.push(client); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +} + +async function mcpPrompt( + client: Client, + args: Record +): Promise { + const result = await client.getPrompt({ name: "hack-init", arguments: args }); + expect(result.messages).toHaveLength(1); + const content = result.messages[0]?.content; + if (content?.type !== "text") { + throw new Error("Expected text prompt content"); + } + return content.text; +} + +/** The prompt `hack init --with` printed between its fallback markers. */ +function extractInitPrompt(stdout: string): string { + const start = stdout.indexOf(INIT_PROMPT_START); + const end = stdout.lastIndexOf(INIT_PROMPT_END); + if (start < 0 || end < start) { + throw new Error("init --with printed no onboarding prompt"); + } + return stdout.slice(start + INIT_PROMPT_START.length, end); +} + +type EntryOutput = { + readonly entryPoint: OnboardingEntryPoint; + readonly text: string; +}; + +/** + * Return one issue per entry point whose output is not exactly its own frame + * around `core`. An empty list is parity. + */ +function parityIssues(opts: { + readonly core: string; + readonly outputs: Readonly>; +}): string[] { + const issues: string[] = []; + for (const [label, output] of Object.entries(opts.outputs)) { + const frame = renderOnboardingFrame({ entryPoint: output.entryPoint }); + if (!output.text.startsWith(frame.before)) { + issues.push(`${label}: framing prefix differs`); + continue; + } + if (!output.text.endsWith(frame.after)) { + issues.push(`${label}: framing suffix differs`); + continue; + } + const core = output.text.slice( + frame.before.length, + output.text.length - frame.after.length + ); + if (core !== opts.core) { + issues.push(`${label}: core differs from the shared builder`); + } + } + return issues; +} + +async function expectedCore(repo: string): Promise { + return renderOnboardingCore({ + context: await collectOnboardingContext({ startDir: repo }), + }); +} + +test("every entry point renders the same discovery-first core for the same repo", async () => { + const fixture = await createFixture(); + const mcp = await connectMcp(fixture.root); + + // Before init: no `.hack/`, so the shared builder picks new-project mode. + const freshCore = await expectedCore(fixture.repo); + expect(freshCore).toContain("stand up hack in this repo"); + const fresh: Record = { + "agent onboard": { + entryPoint: { kind: "agent-onboard" }, + text: await runCli(fixture, ["agent", "onboard", "--path", fixture.repo]), + }, + "agent init": { + entryPoint: { kind: "agent-init" }, + text: await runCli(fixture, [ + "agent", + "init", + "--path", + fixture.repo, + "--client", + "print", + ]), + }, + "mcp hack-init (absolute repoRoot)": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { repoRoot: fixture.repo }), + }, + "mcp hack-init (relative repoRoot)": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { + repoRoot: relative(fixture.root, fixture.repo), + }), + }, + }; + expect(parityIssues({ core: freshCore, outputs: fresh })).toEqual([]); + + // `hack init --with` always runs after the scaffold exists. + const created = extractInitPrompt( + await runCli(fixture, [ + "init", + "--auto", + "--with", + "claude", + "--path", + fixture.repo, + ]) + ); + const existing = extractInitPrompt( + await runCli(fixture, [ + "init", + "--auto", + "--with", + "codex", + "--path", + fixture.repo, + ]) + ); + + const adoptedCore = await expectedCore(fixture.repo); + expect(adoptedCore).toContain("adopt the existing hack setup in this repo"); + expect(adoptedCore).not.toBe(freshCore); + const adopted: Record = { + "init --with (scaffolded)": { + entryPoint: { kind: "init-with", scaffold: "created" }, + text: created, + }, + "init --with (existing)": { + entryPoint: { kind: "init-with", scaffold: "existing" }, + text: existing, + }, + "agent onboard": { + entryPoint: { kind: "agent-onboard" }, + text: await runCli(fixture, ["agent", "onboard", "--path", fixture.repo]), + }, + "agent init": { + entryPoint: { kind: "agent-init" }, + text: await runCli(fixture, [ + "agent", + "init", + "--path", + join(fixture.repo, "apps", "web"), + "--client", + "print", + ]), + }, + "mcp hack-init": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { + repoRoot: fixture.repo, + // Ignored with repoRoot: the repo decides mode, name and dev host. + mode: "new-project", + projectName: "caller-supplied", + devHost: "caller.example.test", + }), + }, + }; + expect(parityIssues({ core: adoptedCore, outputs: adopted })).toEqual([]); +}, 60_000); + +test("the shared core carries the corrected inventory and explicit limits", async () => { + const fixture = await createFixture(); + const core = await expectedCore(fixture.repo); + + const inventoryAt = core.indexOf("## Repo inventory (pre-computed by Hack)"); + const phaseOneAt = core.indexOf("## Phase 1 — Inventory the repo"); + expect(inventoryAt).toBeGreaterThan(0); + expect(phaseOneAt).toBeGreaterThan(inventoryAt); + for (const line of [ + `- Root: ${fixture.repo}`, + "- Workspaces: apps/*, packages/*", + "- Package managers: bun.lock", + "- Nested lockfiles: apps/legacy/package-lock.json", + "- Package manager conflicts: bun, npm", + "- Non-JS runtimes: rust (services/worker/Cargo.toml)", + "- Runtime pins: .nvmrc, package.json#engines, package.json#packageManager", + "- Env files: .env.example", + "- apps/web (web): dev -> vite --port 5173", + "Discovery limitations (check these gaps yourself and report the ones that remain):", + "- Inventory reads file names and manifest keys only; env files, secrets, lockfile contents, and pin values were not read.", + ]) { + expect(core).toContain(line); + } + // Name-only inventory: pin values and env-file contents never appear. + expect(core).not.toContain("bun@1.3.9"); + expect(core).not.toContain(">=22"); + expect(core).not.toContain("DATABASE_URL="); +}); + +test("the hack-init MCP prompt without repoRoot states the missing inventory and keeps its framing", async () => { + const fixture = await createFixture(); + const mcp = await connectMcp(fixture.repo); + + const text = await mcpPrompt(mcp, { + mode: "new-project", + projectName: "demo", + }); + expect(text).toBe( + renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "new-project", + projectName: "demo", + reason: "no-repo-root", + }), + }) + ); + expect(text).toContain( + "- Not run: this `hack-init` MCP prompt request named no `repoRoot`." + ); + // The session cwd is a repo, but without repoRoot nothing is scanned. + expect(text).not.toContain("Repo context:"); + expect(text).not.toContain(fixture.repo); +}); + +test("hack-init MCP refusals are closed and never echo the caller's path", async () => { + const fixture = await createFixture(); + const mcp = await connectMcp(fixture.root); + const missing = join(fixture.root, "missing-secret-path"); + + const error = await mcpPrompt(mcp, { repoRoot: missing }).then( + () => null, + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(Error); + const message = error instanceof Error ? error.message : ""; + expect(message).toContain("E_PROJECT_NOT_FOUND: Path not found."); + expect(message).not.toContain("missing-secret-path"); + expect(message).not.toContain(fixture.root); + + await Bun.write(join(fixture.repo, ".hack", "hack.project.json"), "{}\n"); + const native = await mcpPrompt(mcp, { repoRoot: fixture.repo }).then( + () => null, + (caught: unknown) => caught + ); + const nativeMessage = native instanceof Error ? native.message : ""; + expect(nativeMessage).toContain( + "E_NATIVE_PROJECT_UNSUPPORTED: hack-init could not inventory the repo." + ); + expect(nativeMessage).not.toContain(fixture.repo); +}); + +test("negative control: a drifted entry point fails parity", async () => { + const fixture = await createFixture(); + const core = await expectedCore(fixture.repo); + const onboard: OnboardingEntryPoint = { kind: "agent-onboard" }; + const init: OnboardingEntryPoint = { kind: "agent-init" }; + const initFrame = renderOnboardingFrame({ entryPoint: init }); + + // The pre-HACK-1137 shape: an entry point that renders the phases without + // the repo inventory. + const withoutInventory = renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "new-project", + reason: "no-repo-root", + }), + }); + // One inventory signal dropped in one path only. + const droppedSignal = core.replace( + "- Nested lockfiles: apps/legacy/package-lock.json", + "- Nested lockfiles: none detected" + ); + expect(droppedSignal).not.toBe(core); + + expect( + parityIssues({ + core, + outputs: { + aligned: { entryPoint: onboard, text: core }, + "no inventory": { entryPoint: onboard, text: withoutInventory }, + "dropped signal": { + entryPoint: init, + text: `${initFrame.before}${droppedSignal}${initFrame.after}`, + }, + "frame leaked into core": { entryPoint: onboard, text: `x${core}` }, + "frame missing": { entryPoint: init, text: core }, + }, + }) + ).toEqual([ + "no inventory: core differs from the shared builder", + "dropped signal: core differs from the shared builder", + "frame leaked into core: core differs from the shared builder", + "frame missing: framing suffix differs", + ]); +}); + +test("entry points reach onboarding text only through the shared builder", async () => { + // Static drift guard: only the builder module renders inventory or cores, + // and the MCP prompt alone may render without an inventory. + const allowed: Readonly> = { + "src/agents/onboarding-prompt.ts": [ + "renderInitAssistantInventory", + "renderOnboardingCore", + "onboardingContextWithoutInventory", + "renderOnboardingPrompt", + ], + "src/mcp/server.ts": [ + "onboardingContextWithoutInventory", + "renderOnboardingPrompt", + ], + "src/agents/init-assistant.ts": ["renderInitAssistantInventory"], + }; + const guarded = [ + "renderInitAssistantInventory", + "renderOnboardingCore", + "onboardingContextWithoutInventory", + "renderOnboardingPrompt", + ]; + const violations: string[] = []; + for await (const path of new Bun.Glob("src/**/*.ts").scan({ + cwd: REPO_ROOT, + })) { + const source = await readFile(join(REPO_ROOT, path), "utf8"); + for (const name of guarded) { + if ( + new RegExp(`\\b${name}\\b`).test(source) && + !allowed[path]?.includes(name) + ) { + violations.push(`${path}: ${name}`); + } + } + } + expect(violations).toEqual([]); + + for (const path of [ + "src/commands/agent.ts", + "src/commands/project.ts", + "src/mcp/server.ts", + ]) { + const source = await readFile(join(REPO_ROOT, path), "utf8"); + expect(source, path).toContain("buildOnboardingPrompt("); + } +}); diff --git a/tests/onboarding-prompt.test.ts b/tests/onboarding-prompt.test.ts index aee1c1422..820fb8616 100644 --- a/tests/onboarding-prompt.test.ts +++ b/tests/onboarding-prompt.test.ts @@ -1,18 +1,32 @@ import { expect, test } from "bun:test"; import { INSTRUCTION_SECTIONS } from "../src/agents/instruction-source.ts"; -import { renderOnboardingPrompt } from "../src/agents/onboarding-prompt.ts"; +import { + onboardingContextWithoutInventory, + renderOnboardingCore, +} from "../src/agents/onboarding-prompt.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; import { extractHackInvocations, findGuidanceCommandIssues, } from "./helpers/cli-guidance-commands.ts"; -const NEW_PROJECT_PROMPT = renderOnboardingPrompt({ mode: "new-project" }); -const EXISTING_PROJECT_PROMPT = renderOnboardingPrompt({ - mode: "existing-project", - projectName: "myapp", - devHost: "myapp.hack", +// Repo-free cores: these assertions cover the static guidance only. The +// inventory section and entry-point parity are covered by +// tests/onboarding-entry-parity.test.ts. +const NEW_PROJECT_PROMPT = renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "new-project", + reason: "no-repo-root", + }), +}); +const EXISTING_PROJECT_PROMPT = renderOnboardingCore({ + context: onboardingContextWithoutInventory({ + mode: "existing-project", + projectName: "myapp", + devHost: "myapp.hack", + reason: "no-repo-root", + }), }); const ALL_PROMPTS = { @@ -222,7 +236,12 @@ test("ground rules forbid instruction-file injection and require reporting disco expect(groundRules).toContain("Report discovery limits you hit"); expect(groundRules).toContain("the agent client CLI was missing"); expect(groundRules).toContain("a non-interactive run"); - expect(groundRules).toContain("no-shell `hack-init` MCP prompt"); + expect(groundRules).toContain( + "the inventory's remaining discovery limitations" + ); + expect(groundRules).toContain( + "`hack-init` MCP prompt request without `repoRoot`" + ); expect(groundRules).toContain("defaults to existing-project mode"); } }); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 66edc6a71..4e5c82c3b 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -12,11 +12,9 @@ import { removeCursorRules, renderCursorRules, } from "../src/agents/cursor.ts"; -import { - buildInitAssistantReport, - renderInitAssistantPrompt, -} from "../src/agents/init-assistant.ts"; +import { buildInitAssistantReport } from "../src/agents/init-assistant.ts"; import { renderAgentInitPatterns } from "../src/agents/init-patterns.ts"; +import { buildOnboardingPrompt } from "../src/agents/onboarding-prompt.ts"; import { renderAgentPrimer } from "../src/agents/primer.ts"; import { buildSetupSyncScopeResult } from "../src/commands/setup.ts"; import { renderAgentDocsSnippet } from "../src/mcp/agent-docs.ts"; @@ -305,7 +303,10 @@ test("buildInitAssistantReport captures repo signals", async () => { ).toBe(true); expect(report.envFiles).toContain(".env.example"); - const prompt = renderInitAssistantPrompt({ report }); + const prompt = await buildOnboardingPrompt({ + startDir: repoRoot, + entryPoint: { kind: "agent-init" }, + }); expect(prompt).toContain("hack init"); expect(prompt).toContain("docker-compose.yml"); }); From a4497fe9909dc36af8d01dd501c391d2bbe6b860 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 22:43:29 -0400 Subject: [PATCH 4/9] feat(native): warn value-free on stale env at native exec and audit env keys in native doctor HACK-1140: native image-project and authored native exec now run the same advisory, value-free stale-env check that #301 added for Compose exec. The foreground up/restart records salted HMAC digests of the env selection it delivered once the run is ready (bound to that run id, one 0600 file per instance under .hack/.internal/native-env-fingerprints/), removes its own record when the foreground ends, and a restart drops the record once the previous run stops, so a failed restart leaves none. Exec resolves the same selection again after the command is admitted and writes one closed warning to stderr naming the service and suggesting `hack restart`. HACK-1127: the env-key doctor diagnostic now also runs in native Doctor inside a project, and public-artifact detection walks nested public, static, dist and similar directories in monorepos (bounded depth/directories/entries, skips node_modules and .git, never follows symlinks, value-free subjects). Doctor check timers are cleared once a check settles. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 28 +- .../native-authored-project-restart.ts | 2 + src/backends/native-authored-project-start.ts | 127 ++- src/backends/native-exec-stale-env.ts | 158 ++++ src/backends/native-project-start.ts | 31 + src/commands/doctor.ts | 27 +- src/commands/project-exec-native.ts | 25 +- src/commands/project.ts | 73 +- src/lib/compose-env-fingerprint.ts | 23 + src/lib/doctor-env-key.ts | 144 +++- src/lib/native-authored-command.ts | 135 ++- src/lib/native-env-fingerprint-state.ts | 342 ++++++++ tests/doctor-env-key.test.ts | 117 +++ tests/native-authored-project-start.test.ts | 72 ++ tests/native-ca-doctor-cli.test.ts | 74 +- tests/native-exec-stale-env.test.ts | 803 ++++++++++++++++++ tests/native-project-start.test.ts | 48 ++ 17 files changed, 2157 insertions(+), 72 deletions(-) create mode 100644 src/backends/native-exec-stale-env.ts create mode 100644 src/lib/native-env-fingerprint-state.ts create mode 100644 tests/native-exec-stale-env.test.ts diff --git a/docs/env.md b/docs/env.md index 0729a7fb6..7f16917b8 100644 --- a/docs/env.md +++ b/docs/env.md @@ -247,7 +247,7 @@ into a host command, and `--target` on env and host commands chooses the `host` | `hack down` | `--env`, `--target` | At teardown, for lifecycle down hooks only; omitted reuses a named overlay recorded for the running stack, else the configured default. | None; nothing is delivered to containers. | The native runtime backend refuses `--env`. | | `hack restart` | `--env`, `--target` | When services are recreated; omitted reuses a named overlay recorded for the running stack, else the configured default, and an explicit `--env` (including `base`) wins. | This is the refresh path for the whole stack or the named services. | Authored native projects receive the same selection before dispatch. | | `hack run` | `--env` | Per invocation, for the new one-off container. | Each run resolves again. | The native runtime backend refuses `--env`; restart with the selection first. | -| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose warns on stderr when the service's resolved env changed since it started. | `hack restart ` (or `hack restart --env `) | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. | +| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose and native exec warn on stderr when the service's resolved env changed since it started. | `hack restart ` (or `hack restart --env `); native projects restart as a whole with `hack restart` | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. | | `hack env list` | `--env`, `--service` | Per invocation, read-only; secret values are masked unless `--show-secrets`. | Not needed. | Reads files; no runtime required. | | `hack env get` | `--env`, `--service` | Per invocation; prints exactly one plaintext value to stdout. | Not needed. | Reads files; refuses native project inputs. | | `hack env explain` | `--env`, `--service`, `--target` | Per invocation; reports source, precedence and delivery without the value. | Not needed. | Reads files; no runtime required. | @@ -278,7 +278,21 @@ command's output. The warning never prints a key or value. The check is advisory. Identical env, state written before this check existed, and a service whose last targeted restart failed produce no warning. `hack up `, `hack restart ` and `hack env apply --service ` refresh only the -named services' digests. The native runtime backend does not run this check. +named services' digests. + +Native projects run the same check. The foreground `hack up` or `hack restart` records +value-free digests of the env selection it delivered once the run is ready, in +`.hack/.internal/native-env-fingerprints/` (one file per instance, mode 0600). The +record is bound to that run and removed when the foreground ends. `hack exec` resolves +the same selection again: for a Compose-file (image) project, the env for the run's +recorded env name; for an authored project, the managed values for the recorded +`--env` and `--profile` selection. When they differ, it prints one fixed warning to +stderr that names the service and suggests `hack restart`, since native restart is +whole-project. An image-project exec receives freshly resolved values itself, so its +warning says the running service still uses the env it started with. A restart drops +the record once the previous run stops, so a failed restart produces no warning. A +record from another run, a missing record, or any resolution failure produces no +warning. MCP tools accept no env selector. They run the CLI without `--env`, so each row's omitted-`--env` behavior applies. @@ -543,7 +557,13 @@ no key file exists, the check classifies `HACK_ENV_SECRET_KEY` instead. The chec read-only. It never prints key bytes or absolute paths, and `--fix` does not act on it. Each finding names a project-relative subject and the exact command to run from the project root. `--json` adds an `envKey` object to the check with `status` and -`findings` (`code`, `status`, `subject`, `remedy`). +`findings` (`code`, `status`, `subject`, `remedy`). The native runtime backend's +`hack doctor` runs the same check inside a project. + +Nested public artifact directories are found with a bounded walk from the project +root: at most six levels deep, 4096 directories and 100,000 entries. It skips +`node_modules` and `.git`, does not follow symlinked directories, and reports a path +it cannot spell portably as `/.hack.secret.key`. | Code | Status | Meaning | | --- | --- | --- | @@ -557,7 +577,7 @@ project root. `--json` adds an `envKey` object to the check with `status` and | `env-key-not-regular` | error | the key path is a directory or another non-file entry | | `env-key-unreadable` | error | unreadable, oversized or not UTF-8 | | `env-key-tracked` | error | a file named `.hack.secret.key` is tracked by git at any depth | -| `env-key-public-path` | error | a key copy sits in `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` or `.output/` | +| `env-key-public-path` | error | a key copy sits directly in `public/`, `static/`, `dist/`, `build/`, `out/`, `www/`, `_site/`, `.next/` or `.output/`, at the project root or in a nested package such as `apps/web/public/` | Hack derives the AES-256-GCM key by hashing the key text with SHA-256, so key strength is the entropy of that text. Generated keys are 32 random bytes in base64url. diff --git a/src/backends/native-authored-project-restart.ts b/src/backends/native-authored-project-restart.ts index b653bd464..94fb8bdb6 100644 --- a/src/backends/native-authored-project-restart.ts +++ b/src/backends/native-authored-project-restart.ts @@ -77,6 +77,7 @@ export type NativeAuthoredProjectRestartOptions = { readonly onReady?: ServeOptions["onReady"]; readonly onExitDiagnostic?: ServeOptions["onExitDiagnostic"]; readonly onHookDiagnostic?: ServeOptions["onHookDiagnostic"]; + readonly onEnvironmentSelected?: ServeOptions["onEnvironmentSelected"]; /** Execution seam for sequencing controls; the CLI always uses the real owners. */ readonly dependencies?: Partial; }; @@ -167,6 +168,7 @@ export async function restartNativeAuthoredProject( onReady: opts.onReady, onExitDiagnostic: opts.onExitDiagnostic, onHookDiagnostic: opts.onHookDiagnostic, + onEnvironmentSelected: opts.onEnvironmentSelected, }); } catch (error: unknown) { const startup = diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 8550afb42..90fb6925c 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -1,5 +1,9 @@ import { createHash } from "node:crypto"; import { isAbsolute } from "node:path"; +import { + type ComposeEnvFingerprintRecord, + withComposeServiceEnvFingerprints, +} from "../lib/compose-env-fingerprint.ts"; import { deepFreeze } from "../lib/guards.ts"; import { NativeComposeHostHookError } from "../lib/native-compose-host-contract.ts"; import { NativeConfigCompilerError } from "../lib/native-config-compiler.ts"; @@ -162,8 +166,41 @@ type Options = { ) => undefined; readonly onExitDiagnostic?: (diagnostic: NativeExitDiagnostic) => void; readonly onHookDiagnostic?: (event: NativeHookDiagnostic) => void; + /** + * Advisory stale-env bookkeeping (HACK-1140): value-free digests of the + * managed selection delivered to every selected workload, observed + * synchronously before the graph starts. Grants no authority; a failing + * observer never fails startup. + */ + readonly onEnvironmentSelected?: ( + fingerprint: ComposeEnvFingerprintRecord + ) => undefined; }; +type SelectedEnvironment = Readonly< + Record>> +>; + +function observeSelectedEnvironment( + opts: Options, + selected: SelectedEnvironment +): void { + if (!opts.onEnvironmentSelected) { + return; + } + try { + opts.onEnvironmentSelected( + withComposeServiceEnvFingerprints({ + existing: undefined, + services: Object.keys(selected), + envByService: selected, + }) + ); + } catch { + // Advisory only; startup proceeds without a stale-env record. + } +} + function observedReady( opts: Options, ready: NativeAuthoredProjectRunSelection @@ -211,31 +248,38 @@ function matchingReview( return review; } -/** Project only compiler-selected managed source keys. Rust owns destination remapping. */ -async function privateDelivery( - inputs: Inputs, - review: NativeAuthoredReview, - lifetimeMs: () => number -): Promise { - const sources = Object.entries(inputs.result.environment_plan.workloads) - .map(([name, bindings]) => ({ - name, - keys: [ - ...new Set( - Object.values(bindings).flatMap((binding) => - binding.kind === "managed" ? [binding.key] : [] - ) - ), - ], - })) - .filter(({ keys }) => keys.length > 0); - if (sources.length === 0) { - return undefined; - } - const values = await inputs.resolveManagedValues(); - const services = Object.fromEntries( - sources.map(({ name, keys }) => { - const selected = values[name]; +type EnvironmentPlan = Inputs["result"]["environment_plan"]; + +/** Compiler-selected managed source keys per workload; Rust owns destination remapping. */ +function managedSourceKeys( + environmentPlan: EnvironmentPlan +): readonly { readonly name: string; readonly keys: readonly string[] }[] { + return Object.entries(environmentPlan.workloads).map(([name, bindings]) => ({ + name, + keys: [ + ...new Set( + Object.values(bindings).flatMap((binding) => + binding.kind === "managed" ? [binding.key] : [] + ) + ), + ], + })); +} + +/** + * The managed values startup delivers, per selected workload: only + * compiler-selected source keys. Workloads without managed keys select an + * empty set. Values stay in memory; callers digest or deliver them privately. + */ +export function selectNativeAuthoredManagedEnvironment(opts: { + readonly environmentPlan: EnvironmentPlan; + readonly values: Readonly< + Record> | undefined> + >; +}): Readonly>>> { + return Object.fromEntries( + managedSourceKeys(opts.environmentPlan).map(({ name, keys }) => { + const selected = opts.values[name]; return [ name, Object.fromEntries( @@ -254,6 +298,32 @@ async function privateDelivery( ]; }) ); +} + +/** Private delivery of the selected managed values, or none when nothing is selected. */ +async function privateDelivery( + inputs: Inputs, + review: NativeAuthoredReview, + lifetimeMs: () => number, + observe: (environment: SelectedEnvironment) => void +): Promise { + const environmentPlan = inputs.result.environment_plan; + const managed = managedSourceKeys(environmentPlan).some( + ({ keys }) => keys.length > 0 + ); + const selected = selectNativeAuthoredManagedEnvironment({ + environmentPlan, + values: managed ? await inputs.resolveManagedValues() : {}, + }); + observe(selected); + if (!managed) { + return undefined; + } + const services = Object.fromEntries( + Object.entries(selected).filter( + ([, values]) => Object.keys(values).length > 0 + ) + ); const lifetime = Math.floor(lifetimeMs() / 1000); if (lifetime < 1 || lifetime > 300) { throw new Error("Native managed lifetime expired; values omitted."); @@ -933,7 +1003,12 @@ export async function serveNativeAuthoredProject( attempt = { source, start: await admission.reserve({ review }) }; outcome = "retained"; stage = "runtime"; - payload = await privateDelivery(inputs, review, remaining); + payload = await privateDelivery( + inputs, + review, + remaining, + (selected) => observeSelectedEnvironment(opts, selected) + ); await inputs.assertFresh(); await storageTool?.assertFresh(); await source.assertFresh(); diff --git a/src/backends/native-exec-stale-env.ts b/src/backends/native-exec-stale-env.ts new file mode 100644 index 000000000..6e05dfa3e --- /dev/null +++ b/src/backends/native-exec-stale-env.ts @@ -0,0 +1,158 @@ +import { buildStaleNativeEnvWarning } from "../lib/compose-env-fingerprint.ts"; +import { + evaluateNativeServiceEnvFreshness, + loadNativeEnvFingerprint, + type NativeEnvSelection, +} from "../lib/native-env-fingerprint-state.ts"; +import { acquireNativeExecutionInputs } from "../lib/native-execution-inputs.ts"; +import { + loadNativeAuthoredProjectRun, + type NativeAuthoredProjectRunScope, +} from "./native-authored-project-run.ts"; +import { selectNativeAuthoredManagedEnvironment } from "./native-authored-project-start.ts"; +import { prepareNativeProjectInput } from "./native-project-input.ts"; +import { + loadNativeProjectRun, + type NativeProjectRunScope, +} from "./native-project-run.ts"; + +/** + * Advisory stale-env checks for native `hack exec` (HACK-1140). + * + * Each resolver compares the value-free record the foreground up/restart + * wrote for the current run with the env selection resolved now, and returns + * a closed warning naming the service, or null. Null covers unchanged env, no + * record, a record from another run, and any failure: the check never refuses + * exec and never reports a key or value. Resolution runs only when a record + * for the current run exists. + */ + +/** + * Native image project: the selection is the Compose-file env resolved for + * the run's recorded env name, before adaptation, AWS or runtime metadata, + * exactly as startup digested it. Native exec delivers freshly resolved values + * to the exec itself, so the warning is about the running service. + */ +export async function resolveNativeImageExecStaleEnvWarning(opts: { + readonly scope: NativeProjectRunScope; + readonly service: string; + readonly composeFile: string; + readonly loadRun?: typeof loadNativeProjectRun; + readonly prepare?: typeof prepareNativeProjectInput; +}): Promise { + try { + const entry = await loadNativeEnvFingerprint({ + projectDir: opts.scope.projectDir, + owner: "image", + branch: opts.scope.branch, + }); + if (!entry) { + return null; + } + const mapping = await (opts.loadRun ?? loadNativeProjectRun)(opts.scope); + if ( + !mapping || + mapping.run !== entry.run || + mapping.effectiveEnvName === undefined + ) { + return null; + } + const input = await (opts.prepare ?? prepareNativeProjectInput)({ + projectRoot: opts.scope.projectRoot, + projectDir: opts.scope.projectDir, + composeFile: opts.composeFile, + envName: mapping.effectiveEnvName, + }); + if (input.effectiveEnvName !== mapping.effectiveEnvName) { + return null; + } + const freshness = evaluateNativeServiceEnvFreshness({ + entry, + run: mapping.run, + service: opts.service, + currentEnv: input.managedEnvironment[opts.service] ?? {}, + }); + return freshness === "changed" + ? buildStaleNativeEnvWarning({ service: opts.service, exec: "fresh" }) + : null; + } catch { + return null; + } +} + +/** The run id of the authored scope's published ready run, or null. */ +async function currentAuthoredRun( + scope: NativeAuthoredProjectRunScope +): Promise { + const ready = await loadNativeAuthoredProjectRun(scope); + return ready?.record.receipt.review.provenance.run ?? null; +} + +/** + * The managed selection an authored startup with `selection` would deliver + * now: fresh input acquisition, then the same projection startup digests. + */ +async function resolveAuthoredEnvironment(opts: { + readonly scope: NativeAuthoredProjectRunScope; + readonly selection: NativeEnvSelection; + readonly signal?: AbortSignal; +}): Promise>>>> { + const inputs = await acquireNativeExecutionInputs({ + projectRoot: opts.scope.projectRoot, + ...(opts.selection.profiles === undefined + ? {} + : { profiles: opts.selection.profiles }), + ...(opts.selection.overlay === undefined + ? {} + : { explicitOverlay: opts.selection.overlay }), + compilerBranch: opts.scope.branch ?? undefined, + signal: opts.signal, + }); + return selectNativeAuthoredManagedEnvironment({ + environmentPlan: inputs.result.environment_plan, + values: await inputs.resolveManagedValues(), + }); +} + +/** + * Authored native project: the selection is the compiler-selected managed + * values delivered at startup, resolved again with the recorded env and + * profile selectors. Authored exec runs through the live owner with the env + * the service started with. + */ +export async function resolveNativeAuthoredExecStaleEnvWarning(opts: { + readonly scope: NativeAuthoredProjectRunScope; + readonly service: string; + readonly signal?: AbortSignal; + readonly currentRun?: typeof currentAuthoredRun; + readonly resolveEnvironment?: typeof resolveAuthoredEnvironment; +}): Promise { + try { + const entry = await loadNativeEnvFingerprint({ + projectDir: opts.scope.projectDir, + owner: "authored", + branch: opts.scope.branch, + }); + if (!entry) { + return null; + } + const run = await (opts.currentRun ?? currentAuthoredRun)(opts.scope); + if (run === null || run !== entry.run) { + return null; + } + const environment = await ( + opts.resolveEnvironment ?? resolveAuthoredEnvironment + )({ scope: opts.scope, selection: entry.selection, signal: opts.signal }); + const freshness = evaluateNativeServiceEnvFreshness({ + entry, + run, + service: opts.service, + currentEnv: environment[opts.service] ?? {}, + }); + return freshness === "changed" + ? buildStaleNativeEnvWarning({ service: opts.service, exec: "started" }) + : null; + } catch { + return null; + } +} diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index fa346813a..177abc1c1 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -2,6 +2,10 @@ 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 { + type ComposeEnvFingerprintRecord, + withComposeServiceEnvFingerprints, +} from "../lib/compose-env-fingerprint.ts"; import { isRecord } from "../lib/guards.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { @@ -855,6 +859,17 @@ export async function startNativeProject(opts: { /** Public instance identity delivered as runtime host metadata; omitted delivers none. */ readonly runtimeMetadata?: NativeRuntimeMetadataSelection; readonly before: (input: NativeProjectInput) => Promise; + /** + * Advisory stale-env bookkeeping (HACK-1140): after the run mapping is + * published, receives the run id and value-free digests of the env + * selection resolved for delivery, before adaptation, AWS or runtime + * metadata. `hack exec` resolves the same selection again to compare. A + * failure here never fails startup. + */ + readonly onEnvironmentReady?: (selection: { + readonly run: string; + readonly fingerprint: ComposeEnvFingerprintRecord; + }) => Promise; readonly signal?: AbortSignal; readonly dependencies?: Partial; }): Promise { @@ -914,6 +929,14 @@ export async function startNativeProject(opts: { composeFile: opts.composeFile, envName: selection.envName, }); + // Digest the selection now: later steps adapt the delivered values. + const selectedEnvironment = opts.onEnvironmentReady + ? withComposeServiceEnvFingerprints({ + existing: undefined, + services: input.serviceNames, + envByService: input.managedEnvironment, + }) + : undefined; if ( retained && !opts.restore && @@ -1308,6 +1331,14 @@ export async function startNativeProject(opts: { }); mapping = persistedMapping; } + if (selectedEnvironment) { + await opts + .onEnvironmentReady?.({ + run, + fingerprint: selectedEnvironment, + }) + .catch(() => undefined); + } await hooks?.ready?.(); await opts.onReady?.(); }, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 51a5e22ad..a0c884842 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -533,6 +533,7 @@ const handleDoctor: CommandHandlerFor = async ({ if (native) { return await handleNativeDoctor({ runtime: native, + startDir: resolveDoctorStartDir(args.options.path), browser, json, fix: args.options.fix === true, @@ -1030,6 +1031,8 @@ function resolveDoctorStartDir(pathOption: string | undefined): string { async function handleNativeDoctor(opts: { readonly runtime: NativeRuntimeSelection; + /** Where project discovery starts for project-scoped checks. */ + readonly startDir: string; readonly browser: ReturnType; readonly json: boolean; readonly fix: boolean; @@ -1162,6 +1165,18 @@ async function handleNativeDoctor(opts: { ) ); } + // The env key audit is runtime-independent; outside a project there is no + // key to audit, so native Doctor adds no check there. + if (await findProjectContext(opts.startDir).catch(() => null)) { + results.push( + await runCheck( + progress, + ENV_KEY_CHECK, + () => checkProjectEnvKey({ startDir: opts.startDir }), + { timeoutMs: 5000 } + ) + ); + } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); const reported = await reportDoctorResults({ @@ -4690,16 +4705,17 @@ async function runCheck( opts?: { readonly timeoutMs?: number } ): Promise { const start = Date.now(); + let timer: ReturnType | undefined; try { const res = opts?.timeoutMs ? await Promise.race([ fn(), - new Promise((resolve) => - setTimeout( + new Promise((resolve) => { + timer = setTimeout( () => resolve({ name, status: "warn", message: "Timed out" }), opts.timeoutMs - ) - ), + ); + }), ]) : await fn(); const durationMs = Date.now() - start; @@ -4709,5 +4725,8 @@ async function runCheck( const message = err instanceof Error ? err.message : "Unknown error"; const res: CheckResult = { name, status: "error", message }; return { ...res, durationMs }; + } finally { + // A settled check must not keep the process alive until its deadline. + clearTimeout(timer); } } diff --git a/src/commands/project-exec-native.ts b/src/commands/project-exec-native.ts index f2c8b5ab8..4b611292a 100644 --- a/src/commands/project-exec-native.ts +++ b/src/commands/project-exec-native.ts @@ -1,3 +1,4 @@ +import { resolveNativeImageExecStaleEnvWarning } from "../backends/native-exec-stale-env.ts"; import { nativeProjectExec } from "../backends/native-project-exec.ts"; import type { NativeProjectRunScope } from "../backends/native-project-run.ts"; import type { NativeRuntimeSelection } from "../backends/native-runtime-client.ts"; @@ -6,6 +7,10 @@ import type { NativeRuntimeSelection } from "../backends/native-runtime-client.t * The native branch of `hack exec`: one bounded noninteractive command through the * selected runtime. Captured output is passed through as returned, with a fixed * truncation notice on stderr; the command's exit code is the result. + * + * An advisory value-free check (HACK-1140) warns on stderr, ahead of the + * captured output, when the service's env selection changed since its run + * started. The check never refuses or changes the exec. */ export async function runNativeProjectExec(input: { readonly runtime: NativeRuntimeSelection; @@ -14,15 +19,31 @@ export async function runNativeProjectExec(input: { readonly argv: readonly string[]; readonly workdir: string | undefined; readonly composeFile: string; + /** Seams for unit tests; the CLI always uses the real owners. */ + readonly exec?: typeof nativeProjectExec; + readonly staleEnv?: typeof resolveNativeImageExecStaleEnvWarning; }): Promise { - const result = await nativeProjectExec({ + const service = input.service ?? ""; + const result = await (input.exec ?? nativeProjectExec)({ runtime: input.runtime, scope: input.scope, - service: input.service ?? "", + service, argv: input.argv, workdir: input.workdir, composeFile: input.composeFile, }); + // Checked after the exec is admitted, so a refused request resolves no env. + // Output is captured, so the warning still precedes it. + const warning = await ( + input.staleEnv ?? resolveNativeImageExecStaleEnvWarning + )({ + scope: input.scope, + service, + composeFile: input.composeFile, + }).catch(() => null); + if (warning) { + process.stderr.write(`WARNING: ${warning}\n`); + } process.stdout.write(result.stdout); process.stderr.write(result.stderr); if (result.truncated) { diff --git a/src/commands/project.ts b/src/commands/project.ts index 1fe605145..24f364310 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -209,6 +209,10 @@ import { } from "../lib/logs.ts"; import { tryNativeComposeCommand } from "../lib/native-compose-command.ts"; import { tryNativeComposeOpen } from "../lib/native-compose-open.ts"; +import { + createNativeEnvFingerprintEntry, + createNativeEnvFingerprintSession, +} from "../lib/native-env-fingerprint-state.ts"; import { readNodesRegistry } from "../lib/nodes-registry.ts"; import { parseOpenHostPreference, @@ -4908,6 +4912,13 @@ async function handleNativeUp({ branch, }); const devHost = await resolveRuntimeDevHost({ project, branch }); + const envFingerprintScope = { + projectDir: project.projectDir, + owner: "image" as const, + branch, + }; + const envFingerprints = + createNativeEnvFingerprintSession(envFingerprintScope); const startup: Parameters[0] = { runtime: native, scope: { @@ -4936,6 +4947,10 @@ async function handleNativeUp({ aliasHost: resolveBranchAliasHost({ devHost, cfg }), projectName, }, + onEnvironmentReady: async ({ run, fingerprint }) => + await envFingerprints.record( + createNativeEnvFingerprintEntry({ owner: "image", run, fingerprint }) + ), before: async (input) => { const lifecycle = await runLifecycleUpBeforeAndProcesses({ title: "Lifecycle (native up before)", @@ -4975,13 +4990,68 @@ async function handleNativeUp({ }, }; if (!restart) { - return await startNativeProject(startup); + try { + return await startNativeProject(startup); + } finally { + await envFingerprints.release(); + } } if (!startup.sharedSource) { throw new CliUsageError( "Native restart requires HACK_NATIVE_SHARED_SOURCE=1 before cleanup." ); } + try { + return await restartNativeImageProject({ + ctx, + args, + native, + project, + cfg, + projectName, + branch, + composeProject, + startup, + recovery, + // Stopping the previous run makes its record stale; a failed restart + // then leaves no record, so exec stays silent rather than guessing. + dropEnvFingerprint: () => envFingerprints.drop(), + }); + } finally { + await envFingerprints.release(); + } +} + +async function restartNativeImageProject({ + ctx, + args, + native, + project, + cfg, + projectName, + branch, + composeProject, + startup, + recovery, + dropEnvFingerprint, +}: { + readonly ctx: CliContext; + readonly args: UpArgs; + readonly native: NonNullable< + ReturnType + >; + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly projectName: string; + readonly branch: string | null; + readonly composeProject: string; + readonly startup: Parameters[0]; + readonly recovery?: { + readonly expectAttempt: string; + readonly legacyPid?: number; + }; + readonly dropEnvFingerprint: () => void; +}): Promise { return await restartNativeProject({ scope: startup.scope, envName: startup.envName, @@ -5037,6 +5107,7 @@ async function handleNativeUp({ }); }, down: async () => { + dropEnvFingerprint(); const code = await handleDown({ ctx, deferFinalization: true, diff --git a/src/lib/compose-env-fingerprint.ts b/src/lib/compose-env-fingerprint.ts index 381d7664c..1d8cd4381 100644 --- a/src/lib/compose-env-fingerprint.ts +++ b/src/lib/compose-env-fingerprint.ts @@ -158,6 +158,29 @@ export function buildStaleComposeEnvWarning(opts: { return `Env for ${subject} changed since it started; this exec uses the env it started with. Run \`${restart}\` to apply the current env.`; } +/** + * Closed, value-free warning for a stale running native service (HACK-1140). + * Native restart is whole-project, so the hint never names a service. + * + * - `started`: the exec runs with the env the service started with (authored + * native exec through the live owner). + * - `fresh`: the exec itself received freshly resolved values, but the running + * service keeps its start-time env (native image-project exec). + */ +export function buildStaleNativeEnvWarning(opts: { + readonly service: string; + readonly exec: "started" | "fresh"; +}): string { + const subject = SERVICE_NAME_PATTERN.test(opts.service) + ? `service "${opts.service}"` + : "this service"; + const effect = + opts.exec === "started" + ? "this exec uses the env it started with" + : "the running service still uses the env it started with"; + return `Env for ${subject} changed since it started; ${effect}. Run \`hack restart\` to apply the current env.`; +} + /** Strict parse of a stored record; anything unexpected is treated as absent. */ export function parseComposeEnvFingerprintRecord( value: unknown diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 7f3544a4b..1f02e9015 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -1,5 +1,5 @@ import type { Stats } from "node:fs"; -import { lstat, realpath } from "node:fs/promises"; +import { lstat, readdir, realpath } from "node:fs/promises"; import { join } from "node:path"; import { PROJECT_ENV_KEY_FILENAME } from "../constants.ts"; import { resolveGitPrimaryWorktreeRoot } from "./git-worktree.ts"; @@ -92,7 +92,8 @@ const ENV_KEY_ENV_NAME = "HACK_ENV_SECRET_KEY"; /** * Build-output and static-hosting directories whose contents are commonly - * published. A key file directly inside one is reported as exposed. + * published. A key file directly inside one is reported as exposed, at the + * project root and in nested packages (bounded walk; see below). */ export const ENV_KEY_PUBLIC_ARTIFACT_DIRS = [ "public", @@ -106,6 +107,21 @@ export const ENV_KEY_PUBLIC_ARTIFACT_DIRS = [ ".output", ] as const; +/** Directory names the nested public-artifact walk never enters. */ +const ENV_KEY_PUBLIC_WALK_SKIP: ReadonlySet = new Set([ + "node_modules", + ".git", +]); +/** + * Bounds of the nested public-artifact walk: the deepest artifact directory + * considered (the project root is depth 0), the most directories read, and the + * most directory entries examined. Past a bound the walk stops; it never + * reports a partial path or fails the check. + */ +export const ENV_KEY_PUBLIC_WALK_MAX_DEPTH = 6; +export const ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES = 4096; +export const ENV_KEY_PUBLIC_WALK_MAX_ENTRIES = 100_000; + const STATUS_RANK: Record = { ok: 0, warn: 1, @@ -309,24 +325,128 @@ function portableRelativePath(path: string): string | null { : null; } +function publicPathFinding(relativeDir: string): EnvKeyFinding { + const portable = portableRelativePath(relativeDir); + if (portable === null) { + return { + code: "env-key-public-path", + status: "error", + subject: `/${PROJECT_ENV_KEY_FILENAME}`, + remedy: + "Remove the key copy from the public artifact directory, then rotate the key: a published copy is not secret.", + }; + } + const relative = `${portable}/${PROJECT_ENV_KEY_FILENAME}`; + return { + code: "env-key-public-path", + status: "error", + subject: relative, + remedy: `rm -- ${relative}, then rotate the key: a published copy is not secret.`, + }; +} + +/** Locale-independent order so the walk and its findings are stable. */ +function compareNames(left: string, right: string): number { + if (left === right) { + return 0; + } + return left < right ? -1 : 1; +} + +const PUBLIC_ARTIFACT_DIR_NAMES: ReadonlySet = new Set( + ENV_KEY_PUBLIC_ARTIFACT_DIRS +); + +/** + * Project-relative paths (with `/` separators) of public artifact directories + * below the top level, such as `apps/web/public` in a monorepo. Bounded + * breadth-first walk in name order: symlinked directories are not followed, + * `node_modules` and `.git` are skipped, and unreadable directories are + * passed over. + */ +async function findNestedPublicArtifactDirs(opts: { + readonly projectRoot: string; +}): Promise { + const found: string[] = []; + const queue: { readonly relative: string; readonly depth: number }[] = [ + { relative: "", depth: 0 }, + ]; + let index = 0; + let entries = 0; + while ( + index < queue.length && + index < ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES && + entries < ENV_KEY_PUBLIC_WALK_MAX_ENTRIES + ) { + const current = queue[index++]; + if (!current) { + break; + } + const children = await walkableChildDirectories( + join(opts.projectRoot, current.relative) + ); + entries += children.entries; + const depth = current.depth + 1; + for (const name of children.names) { + const relative = current.relative ? `${current.relative}/${name}` : name; + if (depth >= 2 && PUBLIC_ARTIFACT_DIR_NAMES.has(name)) { + found.push(relative); + } + if (depth < ENV_KEY_PUBLIC_WALK_MAX_DEPTH) { + queue.push({ relative, depth }); + } + } + } + return found; +} + +/** + * Sorted names of the child directories the walk may enter (real directories, + * not symlinks, outside the skip list) and the number of entries read; an + * unreadable directory has none. + */ +async function walkableChildDirectories(path: string): Promise<{ + readonly names: readonly string[]; + readonly entries: number; +}> { + try { + const children = await readdir(path, { withFileTypes: true }); + return { + entries: children.length, + names: children + .filter( + (child) => + child.isDirectory() && !ENV_KEY_PUBLIC_WALK_SKIP.has(child.name) + ) + .map((child) => child.name) + .sort(compareNames), + }; + } catch { + return { names: [], entries: 0 }; + } +} + async function inspectPublicArtifactCopies(opts: { readonly projectRoot: string; }): Promise { const findings: EnvKeyFinding[] = []; - for (const dir of ENV_KEY_PUBLIC_ARTIFACT_DIRS) { - const relative = `${dir}/${PROJECT_ENV_KEY_FILENAME}`; + const present = async (relativeDir: string) => // An entry that exists but cannot be inspected still counts as present. - const present = await lstatOrNull(join(opts.projectRoot, relative)).then( + await lstatOrNull( + join(opts.projectRoot, relativeDir, PROJECT_ENV_KEY_FILENAME) + ).then( (stats) => stats !== null, () => true ); - if (present) { - findings.push({ - code: "env-key-public-path", - status: "error", - subject: relative, - remedy: `rm -- ${relative}, then rotate the key: a published copy is not secret.`, - }); + for (const dir of ENV_KEY_PUBLIC_ARTIFACT_DIRS) { + if (await present(dir)) { + findings.push(publicPathFinding(dir)); + } + } + const nested = await findNestedPublicArtifactDirs(opts).catch(() => []); + for (const dir of nested) { + if (await present(dir)) { + findings.push(publicPathFinding(dir)); } } return findings; diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 9748913fd..431737141 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -25,6 +25,7 @@ import { NativeAuthoredProjectStartError, serveNativeAuthoredProject, } from "../backends/native-authored-project-start.ts"; +import { resolveNativeAuthoredExecStaleEnvWarning } from "../backends/native-exec-stale-env.ts"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; import { CliUsageError } from "../cli/command.ts"; import { display } from "../ui/display.ts"; @@ -33,6 +34,11 @@ import { emitCliResult, HackCliError, okResult } from "./cli-result.ts"; import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import type { NativeComposeCommandOptions } from "./native-compose-command.ts"; import type { NativeComposeSelection } from "./native-compose-selection.ts"; +import { + createNativeEnvFingerprintEntry, + createNativeEnvFingerprintSession, + type NativeEnvFingerprintSession, +} from "./native-env-fingerprint-state.ts"; function unsupported(): never { throw new HackCliError({ @@ -352,6 +358,8 @@ type Seams = { readonly stop?: typeof stopNativeAuthoredProject; readonly exec?: typeof nativeAuthoredProjectExec; readonly run?: typeof nativeAuthoredProjectRun; + /** Advisory stale-env seam; the CLI always uses the real resolver. */ + readonly staleEnv?: typeof resolveNativeAuthoredExecStaleEnvWarning; }; type Runtime = NonNullable>; type RunnerInput = { @@ -406,16 +414,51 @@ async function runStop(input: LifecycleInput): Promise { return 0; } -/** Fresh foreground selection shared by up and the start half of restart. */ -function startupSelection( +/** Value-free stale-env bookkeeping for one foreground lifetime (HACK-1140). */ +function envFingerprintSession( input: LifecycleInput +): NativeEnvFingerprintSession { + return createNativeEnvFingerprintSession({ + projectDir: input.scope.projectDir, + owner: "authored", + branch: input.scope.branch, + }); +} + +/** + * Fresh foreground selection shared by up and the start half of restart. The + * delivered env selection is captured as value-free digests and recorded once + * the run is ready, so exec can warn when the env resolved later differs. + */ +function startupSelection( + input: LifecycleInput, + envFingerprints: NativeEnvFingerprintSession ): Parameters[0] { + const run = randomBytes(16).toString("hex"); return { runtime: input.runtime, scope: input.scope, - run: randomBytes(16).toString("hex"), + run, profiles: input.options.profiles, overlay: input.options.overlay, + onEnvironmentSelected: (fingerprint) => { + envFingerprints.capture( + createNativeEnvFingerprintEntry({ + owner: "authored", + run, + fingerprint, + selection: { + ...(input.options.overlay === undefined + ? {} + : { overlay: input.options.overlay }), + ...(input.options.profiles === undefined + ? {} + : { profiles: input.options.profiles }), + }, + }) + ); + return undefined; + }, startupTimeoutMs: input.startupTimeoutMs, signal: input.signal, forceSignal: input.forceSignal, @@ -428,30 +471,43 @@ function startupSelection( } }, onReady: () => { + envFingerprints.commit(); logger.info({ message: "Project is ready. Press Ctrl-C to stop." }); return undefined; }, }; } -function runStart(input: LifecycleInput): Promise { - return (input.seams.serve ?? serveNativeAuthoredProject)( - startupSelection(input) - ); +async function runStart(input: LifecycleInput): Promise { + const envFingerprints = envFingerprintSession(input); + try { + return await (input.seams.serve ?? serveNativeAuthoredProject)( + startupSelection(input, envFingerprints) + ); + } finally { + await envFingerprints.release(); + } } -function runRestart(input: LifecycleInput): Promise { - return restartNativeAuthoredProject({ - ...startupSelection(input), - onStopped: () => { - logger.info({ - message: - "Selected native project stopped; persistent data is retained. Starting a fresh foreground generation.", - }); - return undefined; - }, - dependencies: { stop: input.seams.stop, serve: input.seams.serve }, - }); +async function runRestart(input: LifecycleInput): Promise { + const envFingerprints = envFingerprintSession(input); + try { + return await restartNativeAuthoredProject({ + ...startupSelection(input, envFingerprints), + onStopped: () => { + // The stopped run's record is stale; a failed start leaves none. + envFingerprints.drop(); + logger.info({ + message: + "Selected native project stopped; persistent data is retained. Starting a fresh foreground generation.", + }); + return undefined; + }, + dependencies: { stop: input.seams.stop, serve: input.seams.serve }, + }); + } finally { + await envFingerprints.release(); + } } /** Two-stage cancellation and the bounded startup budget shared by the lifecycle modes. */ @@ -506,6 +562,7 @@ const RUNNERS: Readonly Promise>> = runtime: input.runtime, perform: input.seams.exec ?? nativeAuthoredProjectExec, contract: COMMAND_CONTRACTS.exec, + beforeOutput: () => warnStaleAuthoredEnv(input), }), run: (input) => runAuthoredCommand({ @@ -529,13 +586,45 @@ const RUNNERS: Readonly Promise>> = restart: (input) => runAuthoredLifecycle(input, "restart", runRestart), }; +/** + * Advisory stale-env check for an admitted authored exec (HACK-1140). It reads + * the value-free record of the current run and, only when one exists, resolves + * the same env selection again. The closed warning goes to stderr ahead of the + * captured output; stdout stays the command's output. It never changes the + * exec or its exit code. + */ +async function warnStaleAuthoredEnv(input: RunnerInput): Promise { + let root: string; + try { + root = await realpath(input.projectRoot); + } catch { + return; + } + const warning = await ( + input.seams.staleEnv ?? resolveNativeAuthoredExecStaleEnvWarning + )({ + scope: { + projectRoot: root, + projectDir: join(root, ".hack"), + nativeHome: input.runtime.home, + branch: input.options.instance ?? null, + }, + service: input.options.service ?? "", + }).catch(() => null); + if (warning) { + process.stderr.write(`WARNING: ${warning}\n`); + } +} + /** * Explicit native dispatch after exact authored-family and adoption selection. * Compose and omitted selections retain their existing owner. Foreground up delegates * to the tagged lifetime owner; ordinary down uses its authenticated stop. Foreground * restart sequences that same stop before a fresh foreground up with the given * selection. Ps and finite service logs observe its saved run; finite exec and - * one-off run send one command through its live owner; + * one-off run send one command through its live owner (a completed exec may + * resolve its run's recorded env selection again, for an advisory value-free + * stale-env warning only); * explicit down --recover uses the stored cleanup owner without input acquisition. * Unsupported requests never start input acquisition or runtime work. */ @@ -647,6 +736,8 @@ async function runAuthoredCommand(input: { readonly signal: AbortSignal; }) => Promise; readonly contract: CommandContract; + /** Advisory step after a validated completion and before any output. */ + readonly beforeOutput?: () => Promise; }): Promise { const controller = new AbortController(); const cancel = () => controller.abort(); @@ -670,6 +761,10 @@ async function runAuthoredCommand(input: { if (controller.signal.aborted) { return 130; } + await input.beforeOutput?.().catch(() => undefined); + if (controller.signal.aborted) { + return 130; + } await Bun.write(Bun.stdout, result.stdout); if (controller.signal.aborted) { return 130; diff --git a/src/lib/native-env-fingerprint-state.ts b/src/lib/native-env-fingerprint-state.ts new file mode 100644 index 000000000..4c9176c1e --- /dev/null +++ b/src/lib/native-env-fingerprint-state.ts @@ -0,0 +1,342 @@ +import { createHash, randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, rename, unlink } from "node:fs/promises"; +import { join } from "node:path"; +import { + type ComposeEnvFingerprintRecord, + type ComposeServiceEnvFreshness, + evaluateComposeServiceEnvFreshness, + parseComposeEnvFingerprintRecord, +} from "./compose-env-fingerprint.ts"; +import { isRecord } from "./guards.ts"; + +/** + * Value-free record of the env selection a native foreground run delivered to + * its services (HACK-1140), so `hack exec` can warn when the env resolved now + * differs from the env a running service started with. + * + * Ownership: the foreground `hack up`/`hack restart` that delivered the env + * owns its record. It writes the record once the run is ready and removes it + * (only if the record is still its own) when the foreground ends. A restart + * drops the scope's record once the previous run is being stopped, so a failed + * restart leaves no record and exec stays silent rather than guessing. + * + * Invariants: + * - The record holds the salted HMAC digests from `compose-env-fingerprint.ts`, + * the run id it belongs to and, for authored projects, the public env and + * profile selectors needed to resolve the same selection again. It never + * holds a key name or value. + * - Comparison is bound to the run id: a record from another run is `unknown`. + * - Everything is advisory. Read failures, malformed or foreign records yield + * no record; write failures never fail startup or exec. + * - Each scope (owner kind plus branch) has its own file, written atomically + * (temporary file plus rename, mode 0600), so concurrent instances of one + * project never overwrite each other's record. + */ + +export type NativeEnvFingerprintOwner = "image" | "authored"; + +export type NativeEnvFingerprintScope = { + /** The project's `.hack` directory. */ + readonly projectDir: string; + readonly owner: NativeEnvFingerprintOwner; + readonly branch: string | null; +}; + +/** Public selectors an authored run was started with; never values. */ +export type NativeEnvSelection = { + /** Absent: the default selection; null: explicit base. */ + readonly overlay?: string | null; + readonly profiles?: readonly string[]; +}; + +export type NativeEnvFingerprintEntry = { + readonly version: typeof NATIVE_ENV_FINGERPRINT_VERSION; + readonly owner: NativeEnvFingerprintOwner; + readonly run: string; + readonly selection: NativeEnvSelection; + readonly fingerprint: ComposeEnvFingerprintRecord; +}; + +export const NATIVE_ENV_FINGERPRINT_VERSION = 1 as const; + +const RUN_PATTERN = /^[a-f0-9]{32}$/; +// Equivalent to normalizeEnvConfigName(value) === value, bounded. +const ENV_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const CONTROL = /\p{Cc}/u; +const MAX_PROFILES = 64; +const MAX_PROFILE_BYTES = 256; +const MAX_RECORD_BYTES = 256 * 1024; +const DIRECTORY = "native-env-fingerprints"; + +/** Absolute path of a scope's record; the file name never carries the branch. */ +export function resolveNativeEnvFingerprintPath( + scope: NativeEnvFingerprintScope +): string { + const key = createHash("sha256") + .update(JSON.stringify({ owner: scope.owner, branch: scope.branch })) + .digest("hex"); + return join(scope.projectDir, ".internal", DIRECTORY, `${key}.json`); +} + +/** Pure, value-free entry for `run`; throws on an invalid run id or selection. */ +export function createNativeEnvFingerprintEntry(opts: { + readonly owner: NativeEnvFingerprintOwner; + readonly run: string; + readonly fingerprint: ComposeEnvFingerprintRecord; + readonly selection?: NativeEnvSelection; +}): NativeEnvFingerprintEntry { + const selection = opts.selection ?? {}; + if (!(RUN_PATTERN.test(opts.run) && validSelection(selection))) { + throw new Error("Invalid native env fingerprint entry; values omitted."); + } + return { + version: NATIVE_ENV_FINGERPRINT_VERSION, + owner: opts.owner, + run: opts.run, + selection: { + ...(selection.overlay === undefined + ? {} + : { overlay: selection.overlay }), + ...(selection.profiles === undefined + ? {} + : { profiles: [...selection.profiles] }), + }, + fingerprint: opts.fingerprint, + }; +} + +/** Atomically replace the scope's record. Throws on I/O failure. */ +export async function saveNativeEnvFingerprint( + scope: NativeEnvFingerprintScope, + entry: NativeEnvFingerprintEntry +): Promise { + if (entry.owner !== scope.owner) { + throw new Error("Native env fingerprint owner mismatch; values omitted."); + } + const path = resolveNativeEnvFingerprintPath(scope); + const directory = join(scope.projectDir, ".internal", DIRECTORY); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const temporary = join(directory, `.${randomBytes(8).toString("hex")}.tmp`); + const handle = await open( + temporary, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, + 0o600 + ); + try { + await handle.writeFile(`${JSON.stringify(entry)}\n`); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, path); + } catch (error) { + await unlink(temporary).catch(() => undefined); + throw error; + } +} + +/** The scope's record, or null when absent, unreadable, malformed or foreign. */ +export async function loadNativeEnvFingerprint( + scope: NativeEnvFingerprintScope +): Promise { + let text: string; + try { + const handle = await open( + resolveNativeEnvFingerprintPath(scope), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const stats = await handle.stat(); + if (!stats.isFile() || stats.size > MAX_RECORD_BYTES) { + return null; + } + text = await handle.readFile({ encoding: "utf8" }); + } finally { + await handle.close(); + } + } catch { + return null; + } + try { + return parseNativeEnvFingerprintEntry(JSON.parse(text), scope.owner); + } catch { + return null; + } +} + +/** + * Remove the scope's record. With `salt`, only a record that still carries + * that salt (the caller's own record) is removed. Never throws. + */ +export async function forgetNativeEnvFingerprint( + scope: NativeEnvFingerprintScope, + opts: { readonly salt?: string } = {} +): Promise { + try { + if (opts.salt !== undefined) { + const current = await loadNativeEnvFingerprint(scope); + if (current?.fingerprint.salt !== opts.salt) { + return; + } + } + await unlink(resolveNativeEnvFingerprintPath(scope)); + } catch { + // Absent or unremovable: exec still compares only within the same run. + } +} + +/** Compare a record for the current `run` with the env resolved now. */ +export function evaluateNativeServiceEnvFreshness(opts: { + readonly entry: NativeEnvFingerprintEntry | null; + readonly run: string; + readonly service: string; + readonly currentEnv: Readonly>; +}): ComposeServiceEnvFreshness { + if (!opts.entry || opts.entry.run !== opts.run) { + return "unknown"; + } + return evaluateComposeServiceEnvFreshness({ + record: opts.entry.fingerprint, + service: opts.service, + currentEnv: opts.currentEnv, + }); +} + +/** + * Bookkeeping for one foreground lifetime. Writes are queued in call order and + * every failure is swallowed: the record is advisory. + */ +export type NativeEnvFingerprintSession = { + /** Remember a value-free entry to commit later (for synchronous observers). */ + readonly capture: (entry: NativeEnvFingerprintEntry) => void; + /** Queue the write of the captured entry, if any. */ + readonly commit: () => void; + /** Queue removal of the scope's record, whoever wrote it. */ + readonly drop: () => void; + /** Capture, commit and wait for the write. */ + readonly record: (entry: NativeEnvFingerprintEntry) => Promise; + /** Wait for queued writes, then remove the record if it is still ours. */ + readonly release: () => Promise; +}; + +export function createNativeEnvFingerprintSession( + scope: NativeEnvFingerprintScope +): NativeEnvFingerprintSession { + let captured: NativeEnvFingerprintEntry | undefined; + let written: string | undefined; + let queue: Promise = Promise.resolve(); + const enqueue = (step: () => Promise) => { + queue = queue.then(step).catch(() => undefined); + }; + const capture = (entry: NativeEnvFingerprintEntry) => { + captured = entry; + }; + const commit = () => { + const entry = captured; + captured = undefined; + if (!entry) { + return; + } + enqueue(async () => { + await saveNativeEnvFingerprint(scope, entry); + written = entry.fingerprint.salt; + }); + }; + return { + capture, + commit, + drop: () => { + enqueue(async () => { + await forgetNativeEnvFingerprint(scope); + }); + }, + record: async (entry) => { + capture(entry); + commit(); + await queue; + }, + release: async () => { + await queue; + const salt = written; + written = undefined; + if (salt !== undefined) { + await forgetNativeEnvFingerprint(scope, { salt }); + } + }, + }; +} + +function validSelection(value: { + readonly overlay?: unknown; + readonly profiles?: unknown; +}): value is NativeEnvSelection { + const overlay = value.overlay; + if ( + overlay !== undefined && + overlay !== null && + !( + typeof overlay === "string" && + overlay.length <= 128 && + ENV_NAME_PATTERN.test(overlay) + ) + ) { + return false; + } + const profiles = value.profiles; + return ( + profiles === undefined || + (Array.isArray(profiles) && + profiles.length <= MAX_PROFILES && + profiles.every( + (profile: unknown) => + typeof profile === "string" && + profile.length > 0 && + Buffer.byteLength(profile) <= MAX_PROFILE_BYTES && + !CONTROL.test(profile) + )) + ); +} + +function parseNativeEnvFingerprintEntry( + value: unknown, + owner: NativeEnvFingerprintOwner +): NativeEnvFingerprintEntry | null { + if ( + !isRecord(value) || + value.version !== NATIVE_ENV_FINGERPRINT_VERSION || + value.owner !== owner || + typeof value.run !== "string" || + !RUN_PATTERN.test(value.run) || + !isRecord(value.selection) + ) { + return null; + } + const rawSelection = value.selection; + const keys = Object.keys(rawSelection); + if (keys.some((key) => key !== "overlay" && key !== "profiles")) { + return null; + } + const selection: { overlay?: unknown; profiles?: unknown } = { + ...(Object.hasOwn(rawSelection, "overlay") + ? { overlay: rawSelection.overlay } + : {}), + ...(Object.hasOwn(rawSelection, "profiles") + ? { profiles: rawSelection.profiles } + : {}), + }; + if (!validSelection(selection)) { + return null; + } + const fingerprint = parseComposeEnvFingerprintRecord(value.fingerprint); + if (!fingerprint) { + return null; + } + return createNativeEnvFingerprintEntry({ + owner, + run: value.run, + fingerprint, + selection, + }); +} diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index 5ff02d382..f6b5f9ac4 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -294,6 +294,123 @@ describe("env key doctor diagnostic", () => { ]); }); + test("nested monorepo public artifact copies are errors with project-relative subjects", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + for (const dir of [ + ["apps", "web", "public"], + ["packages", "site", "dist"], + ["apps", "docs", ".next"], + ]) { + await mkdir(join(root, ...dir), { recursive: true }); + await writeKey({ + path: join(root, ...dir, KEY_FILE), + text: key, + mode: 0o600, + }); + } + + const inspection = await inspect(root); + + expect(inspection.status).toBe("error"); + expect( + inspection.findings.map((finding) => [finding.code, finding.subject]) + ).toEqual([ + ["env-key-public-path", `apps/docs/.next/${KEY_FILE}`], + ["env-key-public-path", `apps/web/public/${KEY_FILE}`], + ["env-key-public-path", `packages/site/dist/${KEY_FILE}`], + ]); + expect(inspection.findings[1]?.remedy).toBe( + `rm -- apps/web/public/${KEY_FILE}, then rotate the key: a published copy is not secret.` + ); + expectValueFree({ output: serialized(inspection), root, secrets: [key] }); + }); + + test("negative control: the nested walk skips node_modules, .git and symlinked directories", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + for (const dir of [ + ["node_modules", "pkg", "dist"], + [".git", "public"], + ["apps", "web", "node_modules", "lib", "public"], + ["outside", "public"], + ]) { + await mkdir(join(root, ...dir), { recursive: true }); + await writeKey({ + path: join(root, ...dir, KEY_FILE), + text: key, + mode: 0o600, + }); + } + await mkdir(join(root, "apps", "linked"), { recursive: true }); + await symlink( + join(root, "outside", "public"), + join(root, "apps", "linked", "public") + ); + // A key directly in a non-artifact directory is not a public copy. + await writeKey({ + path: join(root, "apps", "web", KEY_FILE), + text: key, + mode: 0o600, + }); + + const inspection = await inspect(root); + + expect( + inspection.findings.map((finding) => [finding.code, finding.subject]) + ).toEqual([["env-key-public-path", `outside/public/${KEY_FILE}`]]); + }); + + test("the nested walk is depth bounded", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + const deepest = ["a", "b", "c", "d", "e", "public"]; + const tooDeep = ["a", "b", "c", "d", "e", "f", "public"]; + for (const dir of [deepest, tooDeep]) { + await mkdir(join(root, ...dir), { recursive: true }); + await writeKey({ + path: join(root, ...dir, KEY_FILE), + text: key, + mode: 0o600, + }); + } + + const inspection = await inspect(root); + + expect(inspection.findings.map((finding) => finding.subject)).toEqual([ + `a/b/c/d/e/public/${KEY_FILE}`, + ]); + }); + + test("an unprintable nested path is reported without echoing it", async () => { + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + const odd = "we ird$(x)"; + await mkdir(join(root, odd, "public"), { recursive: true }); + await writeKey({ + path: join(root, odd, "public", KEY_FILE), + text: key, + mode: 0o600, + }); + + const inspection = await inspect(root); + + expect(inspection.findings).toEqual([ + { + code: "env-key-public-path", + status: "error", + subject: `/${KEY_FILE}`, + remedy: + "Remove the key copy from the public artifact directory, then rotate the key: a published copy is not secret.", + }, + ]); + expect(serialized(inspection)).not.toContain(odd); + }); + test("findings are ordered most severe first", async () => { const root = await tempProject(); await writeKey({ path: join(root, KEY_FILE), text: "short", mode: 0o644 }); diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 2ed1462e4..9a0c8f040 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -26,6 +26,10 @@ import { NATIVE_STORAGE_TOOL_MANIFEST, nativeStorageToolManifest, } from "../src/backends/native-authored-storage-tool.ts"; +import { + type ComposeEnvFingerprintRecord, + evaluateComposeServiceEnvFreshness, +} from "../src/lib/compose-env-fingerprint.ts"; import { restoreEnv } from "./helpers/env.ts"; import { writeLifecycleMuxFixture } from "./helpers/lifecycle-mux-fixture.ts"; import { createNativeAuthoredSettlement } from "./helpers/native-authored-settlement.ts"; @@ -582,6 +586,74 @@ macTest( } ); +macTest( + "native frontend observes value-free digests of the delivered managed selection before ready", + async () => { + const selected = await fixture(); + const events: string[] = []; + let fingerprint: ComposeEnvFingerprintRecord | undefined; + const code = await serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + onEnvironmentSelected: (value) => { + events.push("selected"); + fingerprint = value; + return undefined; + }, + onReady: () => { + events.push("ready"); + return undefined; + }, + }); + expect(code).toBe(0); + expect(events).toEqual(["selected", "ready"]); + expect(Object.keys(fingerprint?.services ?? {})).toEqual(["web"]); + expect(JSON.stringify(fingerprint)).not.toContain(CANARY); + expect(JSON.stringify(fingerprint)).not.toContain("TOKEN"); + expect( + evaluateComposeServiceEnvFreshness({ + record: fingerprint, + service: "web", + currentEnv: { TOKEN: CANARY }, + }) + ).toBe("unchanged"); + expect( + evaluateComposeServiceEnvFreshness({ + record: fingerprint, + service: "web", + currentEnv: { TOKEN: "synthetic-edited" }, + }) + ).toBe("changed"); + } +); + +macTest( + "a selection without managed keys digests an empty set and a throwing observer never fails startup", + async () => { + const selected = await fixture({ noManaged: true }); + let fingerprint: ComposeEnvFingerprintRecord | undefined; + expect( + await serveNativeAuthoredProject({ + ...selected, + run, + startupTimeoutMs: 10_000, + onEnvironmentSelected: (value) => { + fingerprint = value; + throw new Error("synthetic observer failure"); + }, + }) + ).toBe(0); + expect( + evaluateComposeServiceEnvFreshness({ + record: fingerprint, + service: "web", + currentEnv: {}, + }) + ).toBe("unchanged"); + } +); + for (const option of [ "planFailure", "foreignPlan", diff --git a/tests/native-ca-doctor-cli.test.ts b/tests/native-ca-doctor-cli.test.ts index 8cbbdc1f7..33eb14474 100644 --- a/tests/native-ca-doctor-cli.test.ts +++ b/tests/native-ca-doctor-cli.test.ts @@ -1,5 +1,12 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; @@ -11,10 +18,19 @@ afterEach(async () => { ); }); -async function invoke(args: string[]) { +const CLI = join(import.meta.dir, "..", "index.ts"); + +async function fixtureRoot(): Promise { const root = await realpath(await mkdtemp(join(tmpdir(), "native-ca-cli-"))); roots.push(root); - const child = Bun.spawn([process.execPath, "index.ts", ...args], { + return root; +} + +/** Runs the source CLI from `cwd` (default: a fresh directory outside any project). */ +async function invoke(args: string[], cwd?: string) { + const root = await fixtureRoot(); + const child = Bun.spawn([process.execPath, CLI, ...args], { + cwd: cwd ?? root, env: privateCliEnv({ root, env: { @@ -68,3 +84,55 @@ test("native global trust refuses the stale Docker export path", async () => { "No Docker-era CA was installed" ); }); + +test("native Doctor audits the project env key value-free, including nested public copies", async () => { + const project = await fixtureRoot(); + await mkdir(join(project, ".hack")); + await writeFile( + join(project, ".hack", "docker-compose.yml"), + "services:\n web:\n image: busybox\n" + ); + const weakKey = "synthetic-weak-key"; + await writeFile(join(project, ".hack.secret.key"), `${weakKey}\n`, { + mode: 0o600, + }); + await chmod(join(project, ".hack.secret.key"), 0o600); + await mkdir(join(project, "apps", "web", "public"), { recursive: true }); + await writeFile( + join(project, "apps", "web", "public", ".hack.secret.key"), + `${weakKey}\n` + ); + + const result = await invoke(["doctor", "--json"], project); + + expect(result.code).toBe(1); + const envelope: unknown = JSON.parse(result.stdout); + expect(envelope).toMatchObject({ + data: { + checks: expect.arrayContaining([ + expect.objectContaining({ + id: "env key", + status: "error", + envKey: expect.objectContaining({ + findings: expect.arrayContaining([ + expect.objectContaining({ + code: "env-key-public-path", + subject: "apps/web/public/.hack.secret.key", + }), + expect.objectContaining({ code: "env-key-wrong-length" }), + ]), + }), + }), + ]), + }, + }); + const output = result.stdout + result.stderr; + expect(output).not.toContain(weakKey); + expect(output).not.toContain(project); +}); + +test("negative control: native Doctor outside a project adds no env key check", async () => { + const result = await invoke(["doctor", "--json"]); + expect(result.code).toBe(0); + expect(result.stdout).not.toContain('"env key"'); +}); diff --git a/tests/native-exec-stale-env.test.ts b/tests/native-exec-stale-env.test.ts new file mode 100644 index 000000000..ad61052cb --- /dev/null +++ b/tests/native-exec-stale-env.test.ts @@ -0,0 +1,803 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + resolveNativeAuthoredExecStaleEnvWarning, + resolveNativeImageExecStaleEnvWarning, +} from "../src/backends/native-exec-stale-env.ts"; +import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; +import { runNativeProjectExec } from "../src/commands/project-exec-native.ts"; +import { + buildStaleNativeEnvWarning, + type ComposeEnvFingerprintRecord, + withComposeServiceEnvFingerprints, +} from "../src/lib/compose-env-fingerprint.ts"; +import { tryNativeAuthoredCommand } from "../src/lib/native-authored-command.ts"; +import { + createNativeEnvFingerprintEntry, + createNativeEnvFingerprintSession, + evaluateNativeServiceEnvFreshness, + forgetNativeEnvFingerprint, + loadNativeEnvFingerprint, + type NativeEnvFingerprintScope, + resolveNativeEnvFingerprintPath, + saveNativeEnvFingerprint, +} from "../src/lib/native-env-fingerprint-state.ts"; + +const SECRET_BEFORE = "synthetic-native-start-value"; +const SECRET_AFTER = "synthetic-native-edited-value"; +const KEY = "SYNTHETIC_NATIVE_TOKEN"; +const RUN = "a".repeat(32); +const OTHER_RUN = "b".repeat(32); + +const roots: string[] = []; +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); + +async function project(): Promise<{ + readonly root: string; + readonly projectDir: string; +}> { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-exec-stale-env-")) + ); + roots.push(root); + const projectDir = join(root, ".hack"); + await mkdir(projectDir, { mode: 0o700 }); + return { root, projectDir }; +} + +function fingerprint( + env: Readonly>>>, + services: readonly string[] = Object.keys(env) +): ComposeEnvFingerprintRecord { + return withComposeServiceEnvFingerprints({ + existing: undefined, + services, + envByService: env, + }); +} + +function imageScope(projectDir: string, branch: string | null = null) { + return { projectDir, owner: "image" as const, branch }; +} + +async function recordImage(opts: { + readonly projectDir: string; + readonly run?: string; + readonly branch?: string | null; + readonly env: Readonly>>>; +}): Promise { + await saveNativeEnvFingerprint( + imageScope(opts.projectDir, opts.branch ?? null), + createNativeEnvFingerprintEntry({ + owner: "image", + run: opts.run ?? RUN, + fingerprint: fingerprint(opts.env), + }) + ); +} + +function expectValueFree(text: string): void { + expect(text).not.toContain(SECRET_BEFORE); + expect(text).not.toContain(SECRET_AFTER); + expect(text).not.toContain(KEY); +} + +describe("native env fingerprint record", () => { + test("round-trips a value-free 0600 record per scope", async () => { + const { projectDir } = await project(); + await recordImage({ projectDir, env: { web: { [KEY]: SECRET_BEFORE } } }); + + const path = resolveNativeEnvFingerprintPath(imageScope(projectDir)); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expectValueFree(await readFile(path, "utf8")); + const entry = await loadNativeEnvFingerprint(imageScope(projectDir)); + expect(entry?.run).toBe(RUN); + expect( + evaluateNativeServiceEnvFreshness({ + entry, + run: RUN, + service: "web", + currentEnv: { [KEY]: SECRET_BEFORE }, + }) + ).toBe("unchanged"); + expect( + evaluateNativeServiceEnvFreshness({ + entry, + run: RUN, + service: "web", + currentEnv: { [KEY]: SECRET_AFTER }, + }) + ).toBe("changed"); + // Bound to the run: a record from another run never compares. + expect( + evaluateNativeServiceEnvFreshness({ + entry, + run: OTHER_RUN, + service: "web", + currentEnv: { [KEY]: SECRET_AFTER }, + }) + ).toBe("unknown"); + }); + + test("scopes do not share records across owners or branches", async () => { + const { projectDir } = await project(); + await recordImage({ projectDir, env: { web: {} } }); + + expect(await loadNativeEnvFingerprint(imageScope(projectDir))).not.toBe( + null + ); + expect( + await loadNativeEnvFingerprint(imageScope(projectDir, "feature")) + ).toBeNull(); + expect( + await loadNativeEnvFingerprint({ + projectDir, + owner: "authored", + branch: null, + }) + ).toBeNull(); + }); + + test("malformed, foreign or tampered records read as absent", async () => { + const { projectDir } = await project(); + const scope = imageScope(projectDir); + await recordImage({ projectDir, env: { web: {} } }); + const path = resolveNativeEnvFingerprintPath(scope); + const valid = JSON.parse(await readFile(path, "utf8")); + for (const tampered of [ + "not json", + JSON.stringify({ ...valid, version: 2 }), + JSON.stringify({ ...valid, owner: "authored" }), + JSON.stringify({ ...valid, run: "../../etc" }), + JSON.stringify({ ...valid, selection: { overlay: "Bad Name" } }), + JSON.stringify({ ...valid, selection: { extra: true } }), + JSON.stringify({ + ...valid, + fingerprint: { ...valid.fingerprint, salt: "short" }, + }), + ]) { + await writeFile(path, tampered); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + } + }); + + test("forget with a salt removes only the caller's own record", async () => { + const { projectDir } = await project(); + const scope = imageScope(projectDir); + await recordImage({ projectDir, env: { web: {} } }); + const first = await loadNativeEnvFingerprint(scope); + await recordImage({ projectDir, run: OTHER_RUN, env: { web: {} } }); + + await forgetNativeEnvFingerprint(scope, { + salt: first?.fingerprint.salt ?? "", + }); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(OTHER_RUN); + + await forgetNativeEnvFingerprint(scope); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + // Forgetting an absent record is a no-op, not a failure. + await forgetNativeEnvFingerprint(scope); + }); + + test("a session records, drops on restart, and releases only its own record", async () => { + const { projectDir } = await project(); + const scope: NativeEnvFingerprintScope = imageScope(projectDir); + const previous = createNativeEnvFingerprintSession(scope); + await previous.record( + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(RUN); + + // A restart drops the stale record once the previous run is stopping. + const restart = createNativeEnvFingerprintSession(scope); + restart.drop(); + restart.capture( + createNativeEnvFingerprintEntry({ + owner: "image", + run: OTHER_RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + restart.commit(); + // The previous foreground exits late; its release must not remove ours. + await previous.release(); + await restart.release(); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + }); + + test("a restart that never becomes ready leaves no record", async () => { + const { projectDir } = await project(); + const scope = imageScope(projectDir); + await recordImage({ projectDir, env: { web: {} } }); + const restart = createNativeEnvFingerprintSession(scope); + restart.drop(); + // Captured at delivery, but never committed: the run never became ready. + restart.capture( + createNativeEnvFingerprintEntry({ + owner: "image", + run: OTHER_RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + await restart.release(); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + }); + + test("an unwritable record never fails the session", async () => { + const { projectDir } = await project(); + await writeFile(join(projectDir, ".internal"), "not a directory"); + const session = createNativeEnvFingerprintSession(imageScope(projectDir)); + await session.record( + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN, + fingerprint: fingerprint({ web: {} }), + }) + ); + await session.release(); + expect(await loadNativeEnvFingerprint(imageScope(projectDir))).toBeNull(); + }); +}); + +describe("native stale env warning text", () => { + test("is closed, names a valid service and suggests a whole-project restart", () => { + expect( + buildStaleNativeEnvWarning({ service: "web", exec: "started" }) + ).toBe( + 'Env for service "web" changed since it started; this exec uses the env it started with. Run `hack restart` to apply the current env.' + ); + expect(buildStaleNativeEnvWarning({ service: "web", exec: "fresh" })).toBe( + 'Env for service "web" changed since it started; the running service still uses the env it started with. Run `hack restart` to apply the current env.' + ); + expect( + buildStaleNativeEnvWarning({ service: "$(bad name)", exec: "started" }) + ).toContain("Env for this service changed"); + }); +}); + +function imageMapping( + overrides: Partial = {} +): NativeProjectRun { + return { + run: RUN, + owner: "c".repeat(32), + namespace: "d".repeat(64), + planId: "e".repeat(64), + effectiveEnvName: null, + profiles: [], + aws: null, + ...overrides, + }; +} + +function preparedInput( + managedEnvironment: NativeProjectInput["managedEnvironment"], + effectiveEnvName: string | null = null +): NativeProjectInput { + return { + originalSha256: "f".repeat(64), + normalizedComposeJson: "{}", + managedEnvironment, + lifecycleHostEnvironment: {}, + effectiveEnvName, + environmentFiles: [], + serviceNames: Object.keys(managedEnvironment), + }; +} + +describe("native image exec stale env check", () => { + async function resolve(opts: { + readonly projectDir: string; + readonly root: string; + readonly mapping?: NativeProjectRun | null; + readonly current?: NativeProjectInput["managedEnvironment"]; + readonly currentEnvName?: string | null; + readonly service?: string; + readonly prepare?: () => Promise; + }) { + const prepared: (string | null | undefined)[] = []; + const warning = await resolveNativeImageExecStaleEnvWarning({ + scope: { + projectRoot: opts.root, + projectDir: opts.projectDir, + nativeHome: opts.root, + branch: null, + }, + service: opts.service ?? "web", + composeFile: join(opts.projectDir, "docker-compose.yml"), + loadRun: async () => + opts.mapping === undefined ? imageMapping() : opts.mapping, + prepare: + opts.prepare ?? + (async (request) => { + prepared.push(request.envName); + return preparedInput( + opts.current ?? { web: { [KEY]: SECRET_BEFORE } }, + opts.currentEnvName ?? null + ); + }), + }); + return { warning, prepared }; + } + + test("warns value-free when the selection changed since the run started", async () => { + const fixture = await project(); + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + + const { warning, prepared } = await resolve({ + ...fixture, + current: { web: { [KEY]: SECRET_AFTER } }, + }); + + expect(warning).toBe( + buildStaleNativeEnvWarning({ service: "web", exec: "fresh" }) + ); + expect(prepared).toEqual([null]); + expectValueFree(warning ?? ""); + }); + + test("negative control: unchanged env does not warn", async () => { + const fixture = await project(); + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + + expect((await resolve(fixture)).warning).toBeNull(); + }); + + test("an added key on a service that started with no env warns", async () => { + const fixture = await project(); + await recordImage({ projectDir: fixture.projectDir, env: { web: {} } }); + + expect( + (await resolve({ ...fixture, current: { web: { [KEY]: SECRET_AFTER } } })) + .warning + ).not.toBeNull(); + }); + + test("no record, another run, a legacy mapping or a changed env name stay silent", async () => { + const fixture = await project(); + const changed = { web: { [KEY]: SECRET_AFTER } }; + const none = await resolve({ ...fixture, current: changed }); + expect(none).toEqual({ warning: null, prepared: [] }); + + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + expect( + await resolve({ + ...fixture, + current: changed, + mapping: imageMapping({ run: OTHER_RUN }), + }) + ).toEqual({ warning: null, prepared: [] }); + expect( + await resolve({ ...fixture, current: changed, mapping: null }) + ).toEqual({ warning: null, prepared: [] }); + const { effectiveEnvName: _legacy, ...legacy } = imageMapping(); + expect( + await resolve({ ...fixture, current: changed, mapping: legacy }) + ).toEqual({ warning: null, prepared: [] }); + expect( + (await resolve({ ...fixture, current: changed, currentEnvName: "qa" })) + .warning + ).toBeNull(); + expect( + (await resolve({ ...fixture, current: changed, service: "worker" })) + .warning + ).toBeNull(); + }); + + test("a resolution failure stays silent", async () => { + const fixture = await project(); + await recordImage({ + projectDir: fixture.projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + + expect( + ( + await resolve({ + ...fixture, + prepare: () => Promise.reject(new Error(SECRET_AFTER)), + }) + ).warning + ).toBeNull(); + }); +}); + +describe("native image exec output", () => { + function captured() { + const stdout: string[] = []; + const stderr: string[] = []; + const out = spyOn(process.stdout, "write").mockImplementation((chunk) => { + stdout.push(String(chunk)); + return true; + }); + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + return { + stdout, + stderr, + restore: () => { + out.mockRestore(); + err.mockRestore(); + }, + }; + } + + const scope = { + projectRoot: "/synthetic/project", + projectDir: "/synthetic/project/.hack", + nativeHome: "/synthetic/native-home", + branch: null, + }; + + test("writes the warning to stderr ahead of the captured output; stdout stays the command's", async () => { + const io = captured(); + let code: number; + try { + code = await runNativeProjectExec({ + runtime: { binary: "/synthetic/hack-native", home: scope.nativeHome }, + scope, + service: "web", + argv: ["printenv"], + workdir: undefined, + composeFile: "/synthetic/project/.hack/docker-compose.yml", + exec: async () => ({ + exitCode: 3, + stdout: Buffer.from("command-out\n"), + stderr: Buffer.from("command-err\n"), + truncated: false, + }), + staleEnv: async (request) => + buildStaleNativeEnvWarning({ + service: request.service, + exec: "fresh", + }), + }); + } finally { + io.restore(); + } + expect(code).toBe(3); + expect(io.stdout).toEqual(["command-out\n"]); + expect(io.stderr).toEqual([ + `WARNING: ${buildStaleNativeEnvWarning({ service: "web", exec: "fresh" })}\n`, + "command-err\n", + ]); + }); + + test("negative control: no warning when unchanged, and none for a refused exec", async () => { + const io = captured(); + let checks = 0; + try { + await runNativeProjectExec({ + runtime: { binary: "/synthetic/hack-native", home: scope.nativeHome }, + scope, + service: "web", + argv: ["true"], + workdir: undefined, + composeFile: "/synthetic/project/.hack/docker-compose.yml", + exec: async () => ({ + exitCode: 0, + stdout: Buffer.from(""), + stderr: Buffer.from(""), + truncated: false, + }), + staleEnv: async () => { + checks++; + return null; + }, + }); + await expect( + runNativeProjectExec({ + runtime: { + binary: "/synthetic/hack-native", + home: scope.nativeHome, + }, + scope, + service: "web", + argv: ["true"], + workdir: undefined, + composeFile: "/synthetic/project/.hack/docker-compose.yml", + exec: () => Promise.reject(new Error("refused")), + staleEnv: async () => { + checks++; + return "unexpected"; + }, + }) + ).rejects.toThrow("refused"); + } finally { + io.restore(); + } + expect(checks).toBe(1); + expect(io.stderr.join("")).not.toContain("WARNING"); + }); +}); + +describe("authored native exec stale env check", () => { + function authoredScope(root: string, projectDir: string) { + return { + projectRoot: root, + projectDir, + nativeHome: join(root, "candidate"), + branch: "feature", + }; + } + + async function recordAuthored(opts: { + readonly projectDir: string; + readonly env: Readonly>>>; + }): Promise { + await saveNativeEnvFingerprint( + { projectDir: opts.projectDir, owner: "authored", branch: "feature" }, + createNativeEnvFingerprintEntry({ + owner: "authored", + run: RUN, + fingerprint: fingerprint(opts.env), + selection: { overlay: "qa", profiles: ["extra"] }, + }) + ); + } + + test("warns when the recorded selection resolves differently now", async () => { + const { root, projectDir } = await project(); + await recordAuthored({ + projectDir, + env: { web: { [KEY]: SECRET_BEFORE }, worker: {} }, + }); + const selections: unknown[] = []; + + const warning = await resolveNativeAuthoredExecStaleEnvWarning({ + scope: authoredScope(root, projectDir), + service: "web", + currentRun: async () => RUN, + resolveEnvironment: async (request) => { + selections.push(request.selection); + return { web: { [KEY]: SECRET_AFTER }, worker: {} }; + }, + }); + + expect(warning).toBe( + buildStaleNativeEnvWarning({ service: "web", exec: "started" }) + ); + // The same public selectors startup used are resolved again. + expect(selections).toEqual([{ overlay: "qa", profiles: ["extra"] }]); + expectValueFree(warning ?? ""); + }); + + test("negative control: unchanged env, another run, or no record stay silent", async () => { + const { root, projectDir } = await project(); + const scope = authoredScope(root, projectDir); + let resolutions = 0; + const resolveEnvironment = async () => { + resolutions++; + return { web: { [KEY]: SECRET_BEFORE } }; + }; + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => RUN, + resolveEnvironment, + }) + ).toBeNull(); + expect(resolutions).toBe(0); + + await recordAuthored({ + projectDir, + env: { web: { [KEY]: SECRET_BEFORE } }, + }); + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => RUN, + resolveEnvironment, + }) + ).toBeNull(); + expect(resolutions).toBe(1); + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => OTHER_RUN, + resolveEnvironment: async () => ({ web: { [KEY]: SECRET_AFTER } }), + }) + ).toBeNull(); + expect( + await resolveNativeAuthoredExecStaleEnvWarning({ + scope, + service: "web", + currentRun: async () => RUN, + resolveEnvironment: () => Promise.reject(new Error(SECRET_AFTER)), + }) + ).toBeNull(); + }); +}); + +const macTest = process.platform === "darwin" ? test : test.skip; + +describe("authored native command wiring", () => { + async function fixture() { + const { root, projectDir } = await project(); + const nativeHome = join(root, "candidate"); + await mkdir(nativeHome, { mode: 0o700 }); + return { + root, + projectDir, + selected: { kind: "native" as const, projectRoot: root }, + env: { + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: join(root, "explicit-native"), + HACK_NATIVE_HOME: nativeHome, + HACK_COMPOSE_STARTUP_TIMEOUT_MS: "1500", + }, + }; + } + + macTest( + "exec writes the warning to stderr before the command's output", + async () => { + const selected = await fixture(); + const stderr: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + const writes = spyOn(Bun, "write").mockImplementation(async () => 0); + let code: number | null; + try { + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: async () => ({ + backend: "native", + run: RUN, + service: "web", + container: "f".repeat(64), + exitCode: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + truncated: false, + }), + staleEnv: async (request) => + buildStaleNativeEnvWarning({ + service: request.service, + exec: "started", + }), + }); + } finally { + err.mockRestore(); + writes.mockRestore(); + } + expect(code).toBe(0); + expect(stderr).toEqual([ + `WARNING: ${buildStaleNativeEnvWarning({ service: "web", exec: "started" })}\n`, + ]); + } + ); + + macTest( + "up records the delivered selection while ready and removes it on exit", + async () => { + const selected = await fixture(); + const scope = { + projectDir: selected.projectDir, + owner: "authored" as const, + branch: null, + }; + const observed: { + entry: Awaited>; + } = { entry: null }; + const code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { cwd: selected.root, operation: "up", overlay: "qa" }, + serve: async (request) => { + request.onEnvironmentSelected?.( + fingerprint({ web: { [KEY]: SECRET_BEFORE } }) + ); + request.onReady?.( + {} as Parameters>[0] + ); + // The ready record is written off the synchronous observer. + for (let attempt = 0; attempt < 50 && !observed.entry; attempt++) { + await Bun.sleep(5); + observed.entry = await loadNativeEnvFingerprint(scope); + } + expect(observed.entry?.run).toBe(request.run); + return 0; + }, + }); + expect(code).toBe(0); + expect(observed.entry?.selection).toEqual({ overlay: "qa" }); + expectValueFree(JSON.stringify(observed.entry)); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + } + ); + + macTest( + "negative control: a run that never becomes ready records nothing", + async () => { + const selected = await fixture(); + const code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { cwd: selected.root, operation: "up" }, + serve: async (request) => { + request.onEnvironmentSelected?.(fingerprint({ web: {} })); + return 0; + }, + }); + expect(code).toBe(0); + expect(await readdir(selected.projectDir)).toEqual([]); + } + ); + + macTest( + "a failed restart start drops the previous run's record", + async () => { + const selected = await fixture(); + const scope = { + projectDir: selected.projectDir, + owner: "authored" as const, + branch: null, + }; + await saveNativeEnvFingerprint( + scope, + createNativeEnvFingerprintEntry({ + owner: "authored", + run: RUN, + fingerprint: fingerprint({ web: { [KEY]: SECRET_BEFORE } }), + }) + ); + await expect( + tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { cwd: selected.root, operation: "restart" }, + stop: async () => undefined, + serve: async (request) => { + request.onEnvironmentSelected?.(fingerprint({ web: {} })); + throw new Error("synthetic start failure"); + }, + }) + ).rejects.toThrow(); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + } + ); +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 4e67a7ff6..4a8721f2d 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -31,6 +31,10 @@ import { verifyHttpsRoutes, } from "../src/backends/native-project-start.ts"; import { NativeRuntimeRequestError } from "../src/backends/native-runtime-client.ts"; +import { + type ComposeEnvFingerprintRecord, + evaluateComposeServiceEnvFreshness, +} from "../src/lib/compose-env-fingerprint.ts"; const roots: string[] = []; afterEach(async () => { @@ -289,6 +293,50 @@ test("foreground saves after readiness and retains mapping after confirmed stop" "cleanup", ]); }); +test("foreground reports value-free digests of the selected env after publishing the mapping", async () => { + const { opts, events } = await fixture(); + const seen: { run: string; fingerprint: ComposeEnvFingerprintRecord }[] = []; + expect( + await startNativeProject({ + ...opts, + onEnvironmentReady: async (selection) => { + events.push("environment"); + seen.push(selection); + }, + }) + ).toBe(0); + expect(events.indexOf("environment")).toBe(events.indexOf("save") + 1); + expect(events.indexOf("environment")).toBeLessThan(events.indexOf("ready")); + expect(seen).toHaveLength(1); + const [selection] = seen; + expect(selection?.run).toMatch(/^[a-f0-9]{32}$/); + expect(JSON.stringify(selection)).not.toContain("synthetic-secret"); + expect(JSON.stringify(selection)).not.toContain("TOKEN"); + expect( + evaluateComposeServiceEnvFreshness({ + record: selection?.fingerprint, + service: "web", + currentEnv: { TOKEN: "synthetic-secret" }, + }) + ).toBe("unchanged"); + expect( + evaluateComposeServiceEnvFreshness({ + record: selection?.fingerprint, + service: "web", + currentEnv: { TOKEN: "synthetic-edited" }, + }) + ).toBe("changed"); +}); +test("a failing env digest observer never fails foreground startup", async () => { + const { opts, events } = await fixture(false); + expect( + await startNativeProject({ + ...opts, + onEnvironmentReady: () => Promise.reject(new Error("unwritable")), + }) + ).toBe(0); + expect(events).toContain("ready"); +}); test("uncertain foreground failure retains published mapping and cleans lifecycle listeners", async () => { const { opts, events } = await fixture(); const original = opts.dependencies.serve; From 7afb80482d814b710b03640fff95329fd3f588b8 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 23:07:29 -0400 Subject: [PATCH 5/9] fix(native): bound the authored exec stale-env check and cover walk and session guards Pass the exec's cancellation signal, bounded by a five-second timeout, into the authored exec stale-env resolution, and settle the advisory step as soon as that signal aborts, so Ctrl-C returns 130 without waiting for input acquisition and a slow check never holds the command's output. Make the nested public-artifact walk bounds injectable for tests and cover the directory and entry bounds, including the documented 4096-directory default. Assert that a late release of a previous foreground session keeps the record a restart just wrote. Document the two exec paths that do not run the stale-env check yet: an authored project on the Compose backend and a project switched with hack config adopt. Co-Authored-By: Claude Opus 5.5 --- docs/env.md | 15 ++- src/lib/doctor-env-key.ts | 18 ++- src/lib/native-authored-command.ts | 92 ++++++++++++--- tests/doctor-env-key.test.ts | 72 ++++++++++++ tests/native-exec-stale-env.test.ts | 173 ++++++++++++++++++++++++++++ 5 files changed, 344 insertions(+), 26 deletions(-) diff --git a/docs/env.md b/docs/env.md index 7f16917b8..aaaf7eaf6 100644 --- a/docs/env.md +++ b/docs/env.md @@ -247,7 +247,7 @@ into a host command, and `--target` on env and host commands chooses the `host` | `hack down` | `--env`, `--target` | At teardown, for lifecycle down hooks only; omitted reuses a named overlay recorded for the running stack, else the configured default. | None; nothing is delivered to containers. | The native runtime backend refuses `--env`. | | `hack restart` | `--env`, `--target` | When services are recreated; omitted reuses a named overlay recorded for the running stack, else the configured default, and an explicit `--env` (including `base`) wins. | This is the refresh path for the whole stack or the named services. | Authored native projects receive the same selection before dispatch. | | `hack run` | `--env` | Per invocation, for the new one-off container. | Each run resolves again. | The native runtime backend refuses `--env`; restart with the selection first. | -| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose and native exec warn on stderr when the service's resolved env changed since it started. | `hack restart ` (or `hack restart --env `); native projects restart as a whole with `hack restart` | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. | +| `hack exec` | `--env` | Checked at invocation against the env recorded for the running stack; the command runs with the env the container was created with. Compose exec and native-runtime exec (Compose-file and authored native projects) warn on stderr when the service's resolved env changed since it started. | `hack restart ` (or `hack restart --env `); native projects restart as a whole with `hack restart` | Compose refuses when the requested env differs from the env recorded for the running stack; the native runtime backend refuses `--env`. | | `hack env list` | `--env`, `--service` | Per invocation, read-only; secret values are masked unless `--show-secrets`. | Not needed. | Reads files; no runtime required. | | `hack env get` | `--env`, `--service` | Per invocation; prints exactly one plaintext value to stdout. | Not needed. | Reads files; refuses native project inputs. | | `hack env explain` | `--env`, `--service`, `--target` | Per invocation; reports source, precedence and delivery without the value. | Not needed. | Reads files; no runtime required. | @@ -280,7 +280,7 @@ service whose last targeted restart failed produce no warning. `hack up ` and `hack env apply --service ` refresh only the named services' digests. -Native projects run the same check. The foreground `hack up` or `hack restart` records +Projects on the native runtime backend run the same check. The foreground `hack up` or `hack restart` records value-free digests of the env selection it delivered once the run is ready, in `.hack/.internal/native-env-fingerprints/` (one file per instance, mode 0600). The record is bound to that run and removed when the foreground ends. `hack exec` resolves @@ -292,7 +292,16 @@ whole-project. An image-project exec receives freshly resolved values itself, so warning says the running service still uses the env it started with. A restart drops the record once the previous run stops, so a failed restart produces no warning. A record from another run, a missing record, or any resolution failure produces no -warning. +warning. An authored-project check is bounded: after five seconds without a result +it is skipped and the command's output and exit code follow as usual. Ctrl-C during +the check ends the command with exit code 130 without waiting for it. + +Two exec paths do not run this check yet: an authored `.hack/hack.project.json` +project on the Compose backend (see +[native configuration with Compose](reference/native-compose-commands.md)), and an +existing Compose project switched with +[`hack config adopt`](reference/native-compose-adoption.md), whose exec runs in the +retained containers. MCP tools accept no env selector. They run the CLI without `--env`, so each row's omitted-`--env` behavior applies. diff --git a/src/lib/doctor-env-key.ts b/src/lib/doctor-env-key.ts index 1f02e9015..5ee08e9df 100644 --- a/src/lib/doctor-env-key.ts +++ b/src/lib/doctor-env-key.ts @@ -364,9 +364,17 @@ const PUBLIC_ARTIFACT_DIR_NAMES: ReadonlySet = new Set( * `node_modules` and `.git` are skipped, and unreadable directories are * passed over. */ -async function findNestedPublicArtifactDirs(opts: { +export async function findNestedPublicArtifactDirs(opts: { readonly projectRoot: string; + /** Test seam for the walk bounds; production uses the documented constants. */ + readonly limits?: { + readonly maxDirectories?: number; + readonly maxEntries?: number; + }; }): Promise { + const maxDirectories = + opts.limits?.maxDirectories ?? ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES; + const maxEntries = opts.limits?.maxEntries ?? ENV_KEY_PUBLIC_WALK_MAX_ENTRIES; const found: string[] = []; const queue: { readonly relative: string; readonly depth: number }[] = [ { relative: "", depth: 0 }, @@ -375,8 +383,8 @@ async function findNestedPublicArtifactDirs(opts: { let entries = 0; while ( index < queue.length && - index < ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES && - entries < ENV_KEY_PUBLIC_WALK_MAX_ENTRIES + index < maxDirectories && + entries < maxEntries ) { const current = queue[index++]; if (!current) { @@ -443,7 +451,9 @@ async function inspectPublicArtifactCopies(opts: { findings.push(publicPathFinding(dir)); } } - const nested = await findNestedPublicArtifactDirs(opts).catch(() => []); + const nested = await findNestedPublicArtifactDirs({ + projectRoot: opts.projectRoot, + }).catch(() => []); for (const dir of nested) { if (await present(dir)) { findings.push(publicPathFinding(dir)); diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 431737141..3124216b5 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -360,6 +360,8 @@ type Seams = { readonly run?: typeof nativeAuthoredProjectRun; /** Advisory stale-env seam; the CLI always uses the real resolver. */ readonly staleEnv?: typeof resolveNativeAuthoredExecStaleEnvWarning; + /** Test seam for the advisory bound; the CLI always uses the default. */ + readonly staleEnvTimeoutMs?: number; }; type Runtime = NonNullable>; type RunnerInput = { @@ -562,7 +564,7 @@ const RUNNERS: Readonly Promise>> = runtime: input.runtime, perform: input.seams.exec ?? nativeAuthoredProjectExec, contract: COMMAND_CONTRACTS.exec, - beforeOutput: () => warnStaleAuthoredEnv(input), + beforeOutput: (signal) => warnStaleAuthoredEnv(input, signal), }), run: (input) => runAuthoredCommand({ @@ -586,36 +588,84 @@ const RUNNERS: Readonly Promise>> = restart: (input) => runAuthoredLifecycle(input, "restart", runRestart), }; +/** + * Upper bound on the advisory stale-env resolution of one authored exec. Past + * it, or on cancellation, the exec output is written without a warning. + */ +export const NATIVE_AUTHORED_EXEC_STALE_ENV_TIMEOUT_MS = 5000; + /** * Advisory stale-env check for an admitted authored exec (HACK-1140). It reads * the value-free record of the current run and, only when one exists, resolves - * the same env selection again. The closed warning goes to stderr ahead of the - * captured output; stdout stays the command's output. It never changes the - * exec or its exit code. + * the same env selection again. The resolution receives the exec's cancellation + * signal bounded by {@link NATIVE_AUTHORED_EXEC_STALE_ENV_TIMEOUT_MS}, and the + * check settles as soon as that signal aborts even if the resolution has not. + * The closed warning goes to stderr ahead of the captured output; stdout stays + * the command's output. It never changes the exec or its exit code. */ -async function warnStaleAuthoredEnv(input: RunnerInput): Promise { +async function warnStaleAuthoredEnv( + input: RunnerInput, + signal: AbortSignal +): Promise { + const bounded = AbortSignal.any([ + signal, + AbortSignal.timeout( + input.seams.staleEnvTimeoutMs ?? NATIVE_AUTHORED_EXEC_STALE_ENV_TIMEOUT_MS + ), + ]); let root: string; try { root = await realpath(input.projectRoot); } catch { return; } - const warning = await ( - input.seams.staleEnv ?? resolveNativeAuthoredExecStaleEnvWarning - )({ - scope: { - projectRoot: root, - projectDir: join(root, ".hack"), - nativeHome: input.runtime.home, - branch: input.options.instance ?? null, - }, - service: input.options.service ?? "", - }).catch(() => null); + const warning = await settleBeforeAbort( + (input.seams.staleEnv ?? resolveNativeAuthoredExecStaleEnvWarning)({ + scope: { + projectRoot: root, + projectDir: join(root, ".hack"), + nativeHome: input.runtime.home, + branch: input.options.instance ?? null, + }, + service: input.options.service ?? "", + signal: bounded, + }), + bounded + ); if (warning) { process.stderr.write(`WARNING: ${warning}\n`); } } +/** + * The advisory value, or null once `signal` aborts or the work fails. The + * abandoned work keeps its own signal and settles on its own; nothing waits + * for it. + */ +function settleBeforeAbort( + work: Promise, + signal: AbortSignal +): Promise { + if (signal.aborted) { + work.catch(() => undefined); + return Promise.resolve(null); + } + return new Promise((resolve) => { + const onAbort = () => resolve(null); + signal.addEventListener("abort", onAbort, { once: true }); + work.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + () => { + signal.removeEventListener("abort", onAbort); + resolve(null); + } + ); + }); +} + /** * Explicit native dispatch after exact authored-family and adoption selection. * Compose and omitted selections retain their existing owner. Foreground up delegates @@ -736,8 +786,12 @@ async function runAuthoredCommand(input: { readonly signal: AbortSignal; }) => Promise; readonly contract: CommandContract; - /** Advisory step after a validated completion and before any output. */ - readonly beforeOutput?: () => Promise; + /** + * Advisory step after a validated completion and before any output. It + * receives the command's cancellation signal and must settle promptly once + * that signal aborts. + */ + readonly beforeOutput?: (signal: AbortSignal) => Promise; }): Promise { const controller = new AbortController(); const cancel = () => controller.abort(); @@ -761,7 +815,7 @@ async function runAuthoredCommand(input: { if (controller.signal.aborted) { return 130; } - await input.beforeOutput?.().catch(() => undefined); + await input.beforeOutput?.(controller.signal).catch(() => undefined); if (controller.signal.aborted) { return 130; } diff --git a/tests/doctor-env-key.test.ts b/tests/doctor-env-key.test.ts index f6b5f9ac4..f3b9283cf 100644 --- a/tests/doctor-env-key.test.ts +++ b/tests/doctor-env-key.test.ts @@ -16,8 +16,10 @@ import { } from "../src/commands/doctor.ts"; import { classifyEnvKeyText, + ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES, type EnvKeyCandidate, type EnvKeyInspection, + findNestedPublicArtifactDirs, gitListTrackedKeyPaths, type InspectEnvKeyOptions, inspectProjectEnvKey, @@ -385,6 +387,76 @@ describe("env key doctor diagnostic", () => { ]); }); + test("the nested walk stops after its directory bound", async () => { + const root = await tempProject(); + for (const pkg of ["a", "b", "c"]) { + await mkdir(join(root, pkg, "public"), { recursive: true }); + } + + // Breadth-first in name order: the root, then a, b and c. + expect(await findNestedPublicArtifactDirs({ projectRoot: root })).toEqual([ + "a/public", + "b/public", + "c/public", + ]); + expect( + await findNestedPublicArtifactDirs({ + projectRoot: root, + limits: { maxDirectories: 3 }, + }) + ).toEqual(["a/public", "b/public"]); + }); + + test("the nested walk stops after its entry bound, counting every entry read", async () => { + const root = await tempProject(); + for (const pkg of ["a", "b"]) { + await mkdir(join(root, pkg, "public"), { recursive: true }); + } + for (const file of ["f1", "f2", "f3"]) { + await writeFile(join(root, file), "x"); + } + + // The root holds five entries; reading a adds one more. + expect( + await findNestedPublicArtifactDirs({ + projectRoot: root, + limits: { maxEntries: 6 }, + }) + ).toEqual(["a/public"]); + expect( + await findNestedPublicArtifactDirs({ + projectRoot: root, + limits: { maxEntries: 7 }, + }) + ).toEqual(["a/public", "b/public"]); + }); + + test("the default directory bound is the documented 4096", async () => { + expect(ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES).toBe(4096); + const root = await tempProject(); + const key = generatedKey(); + await writeKey({ path: join(root, KEY_FILE), text: key, mode: 0o600 }); + // The root is directory 1; "zz" sorts after the fillers. + const fillers = ENV_KEY_PUBLIC_WALK_MAX_DIRECTORIES - 2; + for (let index = 0; index < fillers; index++) { + await mkdir(join(root, `a${String(index).padStart(4, "0")}`)); + } + await mkdir(join(root, "zz", "public"), { recursive: true }); + await writeKey({ + path: join(root, "zz", "public", KEY_FILE), + text: key, + mode: 0o600, + }); + + expect( + (await inspect(root)).findings.map((finding) => finding.subject) + ).toEqual([`zz/public/${KEY_FILE}`]); + + // One more filler pushes "zz" past the bound: it is never read. + await mkdir(join(root, "a9999")); + expect(codes(await inspect(root))).toEqual(["env-key-ok"]); + }, 30_000); + test("an unprintable nested path is reported without echoing it", async () => { const root = await tempProject(); const key = generatedKey(); diff --git a/tests/native-exec-stale-env.test.ts b/tests/native-exec-stale-env.test.ts index ad61052cb..8e1eb7d71 100644 --- a/tests/native-exec-stale-env.test.ts +++ b/tests/native-exec-stale-env.test.ts @@ -219,8 +219,20 @@ describe("native env fingerprint record", () => { }) ); restart.commit(); + // The committed write is queued off the synchronous observer. + let current = await loadNativeEnvFingerprint(scope); + for ( + let attempt = 0; + attempt < 50 && current?.run !== OTHER_RUN; + attempt++ + ) { + await Bun.sleep(5); + current = await loadNativeEnvFingerprint(scope); + } + expect(current?.run).toBe(OTHER_RUN); // The previous foreground exits late; its release must not remove ours. await previous.release(); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(OTHER_RUN); await restart.release(); expect(await loadNativeEnvFingerprint(scope)).toBeNull(); }); @@ -711,6 +723,167 @@ describe("authored native command wiring", () => { } ); + function execCompletion(stdout: string) { + return async () => ({ + backend: "native" as const, + run: RUN, + service: "web", + container: "f".repeat(64), + exitCode: 4, + stdout: new TextEncoder().encode(stdout), + stderr: new Uint8Array(), + truncated: false, + }); + } + + macTest( + "Ctrl-C during the advisory check returns 130 without waiting for it or writing output", + async () => { + const selected = await fixture(); + const stderr: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + const writes = spyOn(Bun, "write").mockImplementation(async () => 0); + const received: { signal?: AbortSignal } = {}; + let code: number | null; + try { + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion("command-out\n"), + staleEnv: (request) => { + received.signal = request.signal; + process.emit("SIGINT"); + // A resolution that ignores cancellation must not hold the exec. + return new Promise(() => undefined); + }, + }); + } finally { + err.mockRestore(); + writes.mockRestore(); + } + expect(code).toBe(130); + expect(received.signal?.aborted).toBe(true); + expect(writes).not.toHaveBeenCalled(); + expect(stderr).toEqual([]); + }, + 2000 + ); + + macTest( + "a slow advisory check is time-bounded: the exec output follows without a warning", + async () => { + const selected = await fixture(); + const stderr: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + const written: string[] = []; + const writes = spyOn(Bun, "write").mockImplementation( + async (_destination, data) => { + written.push(new TextDecoder().decode(data as Uint8Array)); + return 0; + } + ); + const received: { signal?: AbortSignal } = {}; + let code: number | null; + try { + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion("command-out\n"), + staleEnvTimeoutMs: 25, + staleEnv: (request) => { + received.signal = request.signal; + return new Promise(() => undefined); + }, + }); + } finally { + err.mockRestore(); + writes.mockRestore(); + } + expect(code).toBe(4); + expect(received.signal?.aborted).toBe(true); + expect(written).toEqual(["command-out\n", ""]); + expect(stderr).toEqual([]); + }, + 2000 + ); + + macTest( + "negative control: a warning resolved after the bound, or a failed check, is dropped", + async () => { + const selected = await fixture(); + const stderr: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + const writes = spyOn(Bun, "write").mockImplementation(async () => 0); + let code: number | null; + let failedCode: number | null; + try { + failedCode = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion(""), + staleEnv: () => Promise.reject(new Error(SECRET_AFTER)), + }); + code = await tryNativeAuthoredCommand({ + selected: selected.selected, + env: selected.env, + options: { + cwd: selected.root, + operation: "exec", + service: "web", + command: ["printenv"], + }, + exec: execCompletion(""), + staleEnvTimeoutMs: 25, + staleEnv: (request) => + new Promise((resolve) => { + request.signal?.addEventListener("abort", () => + resolve( + buildStaleNativeEnvWarning({ + service: request.service, + exec: "started", + }) + ) + ); + }), + }); + } finally { + err.mockRestore(); + writes.mockRestore(); + } + expect(failedCode).toBe(4); + expect(code).toBe(4); + expect(stderr).toEqual([]); + }, + 2000 + ); + macTest( "up records the delivered selection while ready and removes it on exit", async () => { From 42bd95f562c67930419e5098c83c2fa24f80188a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 12 Oct 2026 23:28:53 -0400 Subject: [PATCH 6/9] fix(native): deliver runtime host metadata to authored projects Authored .hack/hack.project.json projects now receive the same HACK_* runtime host metadata as Compose-file and native image projects, on the authored native path (up/restart; exec and run inherit) and the authored Compose renderer. The config compiler accepts optional env_metadata.runtime values for declared workloads (closed reserved key set, 64 KiB per value, no NUL) and reports them as public `runtime` bindings below managed values and every authored directive. Host entries never receive them. The values come from the shared runtime-host-metadata builder, fed with the compiler's effective routing hosts. runtime-core adds runtime bindings to public workload environment, never to private delivery. HACK-1263 Co-Authored-By: Claude Opus 5.5 --- .codex/skills/hack-cli/SKILL.md | 4 +- .cursor/rules/hack.mdc | 2 +- docs/cli.md | 38 +- docs/guides/native-candidate.md | 10 + docs/reference/native-compose-renderer.md | 1 + docs/reference/native-config-compiler.md | 7 + examples/basic/AGENTS.md | 4 +- examples/basic/CLAUDE.md | 4 +- packages/config-compiler/README.md | 21 +- .../generated/native-config.ts | 4 +- packages/config-compiler/src/environment.rs | 93 +++- packages/config-compiler/src/lib.rs | 2 +- packages/config-compiler/tests/environment.rs | 125 ++++++ packages/config-compiler/tests/protocol.rs | 2 +- packages/runtime-core/src/project/native.rs | 4 +- .../runtime-core/src/project/native/tests.rs | 46 ++ plugins/hack/rules/hack.mdc | 2 +- plugins/hack/skills/hack-branches/SKILL.md | 4 +- plugins/hack/skills/hack-cli/SKILL.md | 4 +- plugins/hack/skills/hack-diagnose/SKILL.md | 2 +- plugins/hack/skills/hack-env/SKILL.md | 2 +- src/agents/instruction-source.ts | 2 +- src/agents/integration-ownership.ts | 2 + src/agents/integration-revision.ts | 2 +- .../native-authored-project-restart.ts | 3 + src/backends/native-authored-project-start.ts | 16 + src/lib/native-authored-command.ts | 25 ++ src/lib/native-authored-runtime-metadata.ts | 178 ++++++++ src/lib/native-compose-command.ts | 17 + src/lib/native-compose-file-command.ts | 4 + src/lib/native-compose-file-sources.ts | 37 ++ src/lib/native-compose-generation.ts | 56 ++- src/lib/native-compose-renderer.ts | 23 + src/lib/native-env-plan-protocol.ts | 44 +- src/lib/native-execution-inputs.ts | 27 ++ src/lib/native-project-validation.ts | 7 + src/lib/runtime-host-metadata.ts | 29 ++ tests/native-authored-command.test.ts | 53 +++ tests/native-authored-project-start.test.ts | 41 ++ .../native-authored-runtime-metadata.test.ts | 415 ++++++++++++++++++ 40 files changed, 1314 insertions(+), 48 deletions(-) create mode 100644 src/lib/native-authored-runtime-metadata.ts create mode 100644 tests/native-authored-runtime-metadata.test.ts diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 7d29dac14..6088d8a6d 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary @@ -160,7 +160,7 @@ Use `hack` as the primary interface for local-first development. - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index d80ad06fa..ac63cee5f 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -9,7 +9,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary diff --git a/docs/cli.md b/docs/cli.md index 659866c5f..6a0ee13cb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -397,11 +397,12 @@ deliberately marks that exact Compose volume disposable. Hack deliberately does ### Runtime host metadata -Containers started by `hack up`, `hack restart`, and `hack run` for a `.hack/docker-compose.yml` -project receive the effective instance and public-route metadata. This applies to the default -Compose backend and to the native backend (`HACK_RUNTIME_BACKEND=native`). It keeps -server-generated links, OAuth callbacks, webhooks, and other browser-facing URLs isolated when the -same project runs in multiple worktrees. Both backends emit the same keys and document shape. +Containers started by `hack up`, `hack restart`, and `hack run` receive the effective instance and +public-route metadata. This applies to `.hack/docker-compose.yml` projects and to authored +`.hack/hack.project.json` projects, on the default Compose backend and on the native backend +(`HACK_RUNTIME_BACKEND=native`). It keeps server-generated links, OAuth callbacks, webhooks, and +other browser-facing URLs isolated when the same project runs in multiple worktrees. Every path +emits the same keys and document shape from the same shared builder. | Variable | Value | | --- | --- | @@ -465,8 +466,31 @@ port; host variables never do. Native-specific limits: removing the native HTTPS frontend when its port is not 443, changes the metadata without a hostname change, so the retained graph refuses to start. Start it with its original HTTPS frontend selection and port. -- Authored `.hack/hack.project.json` configuration does not deliver these variables yet, on either - backend. + +For an authored `.hack/hack.project.json` project, the configuration compiler carries the values as +public `runtime` bindings (see the [compiler reference](reference/native-config-compiler.md)), so +they are reviewed with the rest of the plan and are never managed or secret values. The precedence +matches Compose-file projects: a managed project env value or any authored `environment` directive +for one of these keys (literal, default, reference, endpoint or `unset`) wins over the generated +value. The hosts come from the compiler's routing resolution: + +- `HACK_DEV_HOST` is the resolved project origin's host (for example + `feature-x.demo.hack.local`). Without a routing resolution it is the compiler's generated + `.hack.local`, prefixed with the selected branch when there is one. +- `HACK_ALIAS_HOST` is the host of the alias named by `routes.oauth_alias`, when set. +- Service URLs list each `routes.http` route for that service in route-name order, the route origin + before its aliases, so `HACK_SERVICE_URL` is the development origin. +- `HACK_BRANCH` is the routing resolution's branch, or the selected `--branch` instance when no + routing resolution exists. +- On the Compose backend `HACK_COMPOSE_PROJECT` is the instance's actual Compose project + (`hack-nc-...`), and URLs are portless. On the native backend it is `` or + `--`, as for native image projects, and URLs carry a selected non-443 + `HACK_NATIVE_HTTPS_PORT`. Authored native graphs are not routed, so they have no alias and no + service URLs; the port appears only in `HACK_DEV_URL`. + +Authored native `hack exec` sees the values stored in the running container, and native one-off +`hack run` uses the admitted service environment. A graph started before this release gains them +on its next `hack up` or `hack restart`. Linked worktrees also inherit the project secret key automatically from the primary checkout through the shared git common dir, so you don't need to copy `.hack.secret.key` by hand. Set diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index cca0bfe49..e68bb6171 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -596,6 +596,16 @@ rest of this section describes the older Compose-file native frontend for `.hack/hack.config.json` projects with a Compose file, which keeps its own command coverage below. +Authored native `up` and `restart` deliver the same +[runtime host metadata](../cli.md#runtime-host-metadata) as native image projects. +The configuration compiler carries it as public `runtime` bindings in the reviewed +source selection, below managed values and authored directives, and the runtime +adds it to each workload's public environment, never to private delivery. A +selected non-443 `HACK_NATIVE_HTTPS_PORT` appears in `HACK_DEV_URL` (authored +native graphs are not routed, so they have no alias or service URLs); a partial HTTPS selection refuses before any input or runtime +work. `exec` observes the values stored in the running container, and one-off +`run` uses the admitted environment. + Explicit `HACK_RUNTIME_BACKEND=native` with absolute `HACK_NATIVE_BINARY` and `HACK_NATIVE_HOME` selects native observations for normal `hack ps` and bounded `hack logs SERVICE --no-follow`. They verify the project/branch run mapping diff --git a/docs/reference/native-compose-renderer.md b/docs/reference/native-compose-renderer.md index 432ce3422..0f9a0f269 100644 --- a/docs/reference/native-compose-renderer.md +++ b/docs/reference/native-compose-renderer.md @@ -60,6 +60,7 @@ portable semantic hashes, or write it into the authored configuration directory. | Successful job dependency | `service_completed_successfully`; jobs share the generated Compose services namespace. | | Exec readiness | `CMD` plus ordered argv; explicit shell source becomes `CMD /bin/sh -c`. | | Literal, default, managed reference, unset | Deliver exactly the compiler's effective destination bindings; absent destinations stay absent and empty strings stay present. | +| Generated runtime host metadata | Public `runtime` bindings render as plain environment values. The renderer refuses one for a key outside the reserved `HACK_*` metadata set, or where an authored directive or a supplied managed value owns the key. | | HTTP/HTTPS service endpoint | Explicit `protocol://service-name:port` using project-scoped Compose service DNS. | | HTTP/HTTPS external binding | Explicit `protocol://hostname:port`; external loopback retains its container-context meaning and is not translated into a host gateway. | diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 112d0c8d0..cfb112212 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -605,6 +605,13 @@ For host declarations, metadata additionally includes `host:{default?:{KEY:{scope,secret}},workloads:{TARGET:{KEY:{scope,secret}}}}`. The default map is present exactly when requested, and workload maps exactly match `host_env_targets.workloads`, including empty maps. Extra target authority refuses. +Optional `runtime:{WORKLOAD:{KEY:string}}` carries Hack-generated, non-secret +[runtime host metadata](../cli.md#runtime-host-metadata) for declared workloads. +Only the ten reserved `HACK_*` metadata keys are accepted, each value at most +64 KiB without NUL. The compiler reports them as public `{kind:"runtime",value}` +bindings below managed values and every authored directive, so explicit values +win. Host entries never receive them, and they do not change any hash in the +authored plan or local resolution. Protocol capability: `runtime_env_plan_version: 1`. The compiler shares an 8 MiB output safety budget across the complete planning envelope. It charges symbolic baselines before cloning them for each invocation, plus authored directives and diagnostics before report growth. Accounting is diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index 6f94c3479..74509c9de 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -136,7 +136,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Project targeting: - From repo root, commands use that project automatically. diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index 6f94c3479..74509c9de 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -136,7 +136,7 @@ Branch instances (parallel envs): - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. Project targeting: - From repo root, commands use that project automatically. diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md index e0e151160..05254f907 100644 --- a/packages/config-compiler/README.md +++ b/packages/config-compiler/README.md @@ -119,11 +119,28 @@ and encoded-request bounds. Managed values, ciphertext and paths are unsupported Successful parsing returns `ok: true` and the unchanged plan, semantic hash, local resolution and declared namespace, plus `environment_plan`. That object contains `plan_version: 1`, selection metadata, `complete`, selected `workloads`, `warnings` -and `diagnostics`. Bindings have one of three forms: +and `diagnostics`. Bindings have one of these forms: - `managed`: symbolic `key`, winning `scope`, and `secret` flag; - `literal`: the explicit public authored `value`; -- `default`: the explicit public fallback `value` used when no baseline key exists. +- `default`: the explicit public fallback `value` used when no baseline key exists; +- `endpoint`: a typed endpoint reference and target (see endpoint planning); +- `runtime`: a Hack-generated public runtime host metadata `value`. + +Metadata may also include optional `runtime: {WORKLOAD: {KEY: value}}`: public, +Hack-generated runtime host metadata (`HACK_BRANCH`, `HACK_COMPOSE_PROJECT`, +`HACK_DEV_HOST`, `HACK_DEV_URL`, `HACK_ALIAS_HOST`, `HACK_ALIAS_URL`, +`HACK_SERVICE_NAME`, `HACK_SERVICE_URL`, `HACK_SERVICE_URLS` and +`HACK_RUNTIME_METADATA`). Only those closed keys are accepted, only for declared +workloads, with string values up to 64 KiB that contain no NUL. Anything else +refuses with a fixed request diagnostic that does not echo values. These values +are never managed or secret: they become `runtime` bindings for that selected +workload only where neither a managed baseline key nor an authored directive owns +the key. Managed values replace them, and every authored directive (literal, +default, reference, endpoint or unset) wins over them. Host process entries never +receive runtime bindings. The protocol advertises `runtime_env_plan_version: 1`; +an older compiler refuses the unknown field. Supplying runtime metadata changes +neither the authored plan nor the semantic or resolution hashes. Managed baseline presence includes an empty managed value. `unset` removes a binding. References always read the immutable owning baseline before authored diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index 28c48d4f0..3a3405d9d 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -1,10 +1,10 @@ // Generated by hack-config-compiler; do not edit. export type ManagedBindingMetadata = { scope: string, secret: boolean, }; -export type EnvMetadata = { metadata_version: 1, overlay: string | null, overlay_exists: boolean, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, inactive_scopes: Array, host?: HostMetadata, }; +export type EnvMetadata = { metadata_version: 1, overlay: string | null, overlay_exists: boolean, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, inactive_scopes: Array, host?: HostMetadata, runtime?: { [key in string]: { [key in string]: string } }, }; export type HostMetadata = { default?: { [key in string]: ManagedBindingMetadata }, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, }; export type HostEnvironmentPlan = { env_target: HostEnvTarget, bindings: { [key in string]: EnvironmentBinding }, }; export type EnvPlanRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, global_domain?: string, explicit_domain?: string, branch?: string, env_metadata: EnvMetadata, }; -export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, } | { "kind": "endpoint", reference: EndpointReference, target: EndpointTarget, }; +export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, } | { "kind": "endpoint", reference: EndpointReference, target: EndpointTarget, } | { "kind": "runtime", value: string, }; export type FileConfig = { file: string, }; export type FileSecret = { file: string, } | { env_ref: string, }; export type FileKind = "config" | "secret"; diff --git a/packages/config-compiler/src/environment.rs b/packages/config-compiler/src/environment.rs index 1f115ab3c..9d711df25 100644 --- a/packages/config-compiler/src/environment.rs +++ b/packages/config-compiler/src/environment.rs @@ -35,7 +35,34 @@ pub struct EnvMetadata { #[schemars(with = "HostMetadata")] #[ts(optional, type = "HostMetadata")] pub host: Option, + // Hack-generated, non-secret runtime host metadata per declared workload. + // Values are public plan data, never managed or private-delivered values. + // Only the closed RUNTIME_METADATA_KEYS set is accepted; authored directives + // and managed project values for the same key take precedence. + #[serde( + default, + deserialize_with = "crate::model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "BTreeMap>")] + #[ts(optional, type = "{ [key in string]: { [key in string]: string } }")] + pub runtime: Option>>, } +/// The closed environment keys Hack generates as public runtime host metadata. +pub const RUNTIME_METADATA_KEYS: [&str; 10] = [ + "HACK_ALIAS_HOST", + "HACK_ALIAS_URL", + "HACK_BRANCH", + "HACK_COMPOSE_PROJECT", + "HACK_DEV_HOST", + "HACK_DEV_URL", + "HACK_RUNTIME_METADATA", + "HACK_SERVICE_NAME", + "HACK_SERVICE_URL", + "HACK_SERVICE_URLS", +]; +/// Per-value bound for generated runtime metadata; the whole request remains bounded separately. +pub const MAX_RUNTIME_METADATA_VALUE_BYTES: usize = 64 * 1024; #[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct HostMetadata { @@ -98,6 +125,11 @@ pub enum EnvironmentBinding { reference: crate::endpoint::EndpointReference, target: crate::endpoint::EndpointTarget, }, + /// Hack-generated public runtime host metadata. Never secret or private-delivered; + /// present only where no authored directive or managed value owns the key. + Runtime { + value: String, + }, } #[derive(Debug, Serialize, JsonSchema, TS)] pub struct EnvironmentPlan { @@ -234,6 +266,18 @@ fn read_metadata(document: &mut json::Document) -> Result bool return false; } } - if !validate_host_metadata(metadata, resolved) { + if !validate_host_metadata(metadata, resolved) || !validate_runtime_metadata(metadata, resolved) + { return false; } let mut seen = BTreeSet::new(); @@ -325,6 +370,19 @@ fn validate_metadata(metadata: &EnvMetadata, resolved: &local::Resolved) -> bool || !seen.insert(scope) }) } +/// Generated metadata may target only declared workloads and only the closed key set. +fn validate_runtime_metadata(metadata: &EnvMetadata, resolved: &local::Resolved) -> bool { + metadata.runtime.as_ref().is_none_or(|runtime| { + runtime.iter().all(|(name, values)| { + resolved.compiled.declared_workloads.contains_key(name) + && values.iter().all(|(key, value)| { + RUNTIME_METADATA_KEYS.contains(&key.as_str()) + && value.len() <= MAX_RUNTIME_METADATA_VALUE_BYTES + && !value.contains('\0') + }) + }) + }) +} fn plan_inner( bytes: &[u8], profiles: &[String], @@ -424,6 +482,10 @@ fn bind( BindingOwner { cache_key: &format!("workload/{name}"), endpoint_context: crate::endpoint::EndpointContext::Workload, + runtime: metadata + .runtime + .as_ref() + .and_then(|runtime| runtime.get(name)), }, )?; output.workloads.insert(name.clone(), bindings); @@ -456,6 +518,7 @@ fn bind( BindingOwner { cache_key: &cache_key, endpoint_context: crate::endpoint::EndpointContext::Host, + runtime: None, }, )?; plans.insert( @@ -476,6 +539,8 @@ fn bind( struct BindingOwner<'a> { cache_key: &'a str, endpoint_context: crate::endpoint::EndpointContext, + /// Generated container metadata; host processes never receive it. + runtime: Option<&'a BTreeMap>, } fn bind_environment( resolved: &local::Resolved, @@ -487,10 +552,28 @@ fn bind_environment( owner: BindingOwner<'_>, ) -> Result, local::ResolveDiagnostic> { budget.baseline(owner.cache_key, baseline, resolved, pointer)?; - let mut bindings: BTreeMap = baseline - .iter() - .map(|(key, b)| (key.clone(), managed(key, b))) - .collect(); + // Precedence, lowest first: generated runtime metadata, managed project values, + // then authored directives. Explicit authored and managed values always win. + let mut bindings: BTreeMap = BTreeMap::new(); + if let Some(runtime) = owner.runtime { + budget.value(runtime, resolved, pointer)?; + budget.charge(32 * runtime.len(), resolved, pointer)?; + for (key, value) in runtime { + if !baseline.contains_key(key) { + bindings.insert( + key.clone(), + EnvironmentBinding::Runtime { + value: value.clone(), + }, + ); + } + } + } + bindings.extend( + baseline + .iter() + .map(|(key, b)| (key.clone(), managed(key, b))), + ); for (dest, directive) in directives { let pointer = json::child(&format!("{pointer}/environment"), dest); // Conservatively retain charges for overwritten/unset entries rather than refunding allocation. diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index a68c72c22..4be8dc765 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -406,5 +406,5 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { } pub fn protocol() -> Value { - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1,"runtime_env_plan_version":1}) } diff --git a/packages/config-compiler/tests/environment.rs b/packages/config-compiler/tests/environment.rs index 558b6a76e..d9fa71806 100644 --- a/packages/config-compiler/tests/environment.rs +++ b/packages/config-compiler/tests/environment.rs @@ -285,3 +285,128 @@ fn missing_overlay_warning_tracks_local_and_explicit_selection_provenance() { assert_eq!(warning["line"], line + 1); assert_eq!(warning["column"], content.find("\"qa\"").unwrap() + 1); } +fn runtime_values(service: &str) -> Value { + json!({ + "HACK_BRANCH":"feature", + "HACK_COMPOSE_PROJECT":"example--feature", + "HACK_DEV_HOST":"feature.example.hack.local", + "HACK_DEV_URL":"https://feature.example.hack.local:8443", + "HACK_RUNTIME_METADATA":"{\"version\":1}", + "HACK_SERVICE_NAME":service, + "HACK_SERVICE_URLS":"[]" + }) +} +#[test] +fn runtime_metadata_binds_public_values_below_managed_and_authored_env() { + let mut p = project(); + p["services"]["web"]["environment"] = json!({ + "HACK_DEV_URL":{"literal":"https://authored.example"}, + "HACK_BRANCH":{"default":"authored-default"}, + "HACK_SERVICE_URLS":{"unset":true}, + "HACK_RUNTIME_METADATA":{"env_ref":"SOURCE"} + }); + let mut m = metadata(); + m["workloads"]["web"] = + json!({"HACK_COMPOSE_PROJECT":entry("web",false),"SOURCE":entry("global",false)}); + m["runtime"] = json!({"web":runtime_values("web"),"init":runtime_values("init")}); + let out = output(&request(p, m), &[]); + assert_eq!(out["ok"], true, "{out}"); + assert_eq!(out["environment_plan"]["complete"], true, "{out}"); + let web = &out["environment_plan"]["workloads"]["web"]; + // Authored directives win over generated metadata. + assert_eq!( + web["HACK_DEV_URL"], + json!({"kind":"literal","value":"https://authored.example"}) + ); + assert_eq!( + web["HACK_BRANCH"], + json!({"kind":"default","value":"authored-default"}) + ); + assert!(web.get("HACK_SERVICE_URLS").is_none()); + assert_eq!(web["HACK_RUNTIME_METADATA"]["kind"], "managed"); + // Managed project values win over generated metadata. + assert_eq!( + web["HACK_COMPOSE_PROJECT"], + json!({"kind":"managed","key":"HACK_COMPOSE_PROJECT","scope":"web","secret":false}) + ); + // Keys nothing else owns receive the public runtime binding. + assert_eq!( + web["HACK_DEV_HOST"], + json!({"kind":"runtime","value":"feature.example.hack.local"}) + ); + assert_eq!( + web["HACK_SERVICE_NAME"], + json!({"kind":"runtime","value":"web"}) + ); + let init = &out["environment_plan"]["workloads"]["init"]; + assert_eq!( + init["HACK_SERVICE_NAME"], + json!({"kind":"runtime","value":"init"}) + ); + assert_eq!( + init["HACK_DEV_URL"], + json!({"kind":"runtime","value":"https://feature.example.hack.local:8443"}) + ); + assert_eq!(init.as_object().unwrap().len(), 7); +} +#[test] +fn runtime_metadata_is_absent_without_input_and_never_reaches_host_env() { + let p = project(); + let without = output(&request(p.clone(), metadata()), &[]); + let mut m = metadata(); + m["runtime"] = json!({"web":runtime_values("web")}); + let with = output(&request(p.clone(), m.clone()), &[]); + assert_eq!(without["environment_plan"]["workloads"]["web"], json!({})); + assert_eq!(with["semantic_hash"], without["semantic_hash"]); + assert_eq!(with["plan"], without["plan"]); + assert_eq!( + with["local_resolution"]["resolution_hash"], + without["local_resolution"]["resolution_hash"] + ); + assert_eq!( + with["environment_plan"]["workloads"]["init"], + json!({}), + "only workloads with supplied metadata receive it" + ); + let mut hosted = project(); + hosted["host"] = json!({"processes":{"watch":{"command":{"shell":"exit 0"}}}}); + m["host"] = json!({"default":{},"workloads":{}}); + let out = output(&request(hosted, m), &[]); + assert_eq!(out["ok"], true, "{out}"); + assert_eq!( + out["environment_plan"]["host"]["watch"]["bindings"], + json!({}) + ); + assert_eq!( + out["environment_plan"]["workloads"]["web"]["HACK_BRANCH"]["kind"], + "runtime" + ); +} +#[test] +fn runtime_metadata_shape_keys_workloads_and_values_are_closed() { + for runtime in [ + json!([]), + json!({"web":[]}), + json!({"web":{"HACK_BRANCH":1}}), + json!({"web":{"HACK_BRANCH":null}}), + json!({"web":{"SECRET_TOKEN":"private-value"}}), + json!({"web":{"HACK_OTHER":"private-value"}}), + json!({"absent":{"HACK_BRANCH":"private-value"}}), + json!({"web":{"HACK_BRANCH":"private-value\u{0}"}}), + json!({"web":{"HACK_RUNTIME_METADATA":"x".repeat(64 * 1024 + 1)}}), + Value::Null, + ] { + let mut m = metadata(); + m["runtime"] = runtime.clone(); + let v = output(&request(project(), m), &[]); + assert_eq!(v["ok"], false, "{runtime}"); + assert_eq!(v["diagnostics"][0]["document"], "request", "{runtime}"); + assert!( + !v.to_string().contains("private-value"), + "values are not echoed" + ); + } + let mut m = metadata(); + m["runtime"] = json!({"web":{"HACK_RUNTIME_METADATA":"x".repeat(64 * 1024)}}); + assert_eq!(output(&request(project(), m), &[])["ok"], true); +} diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs index 5a9f34f3a..3476c7ec2 100644 --- a/packages/config-compiler/tests/protocol.rs +++ b/packages/config-compiler/tests/protocol.rs @@ -21,7 +21,7 @@ fn handshake_and_compile_need_no_environment_or_host_tools() { let protocol: Value = serde_json::from_slice(&handshake.stdout).unwrap(); assert_eq!( protocol, - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1,"endpoint_plan_version":1,"process_plan_version":1,"acquisition_plan_version":1,"network_plan_version":1,"file_plan_version":1,"runtime_env_plan_version":1}) ); let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); assert!(result.status.success()); diff --git a/packages/runtime-core/src/project/native.rs b/packages/runtime-core/src/project/native.rs index ea08b6061..f2b599be3 100644 --- a/packages/runtime-core/src/project/native.rs +++ b/packages/runtime-core/src/project/native.rs @@ -553,8 +553,10 @@ fn compile_inputs( return Err(private_refused()); } } + // Generated runtime metadata is public plan data, never private delivery. EnvironmentBinding::Literal { value } - | EnvironmentBinding::Default { value } => { + | EnvironmentBinding::Default { value } + | EnvironmentBinding::Runtime { value } => { inputs .environment .insert(destination.clone(), value.clone()); diff --git a/packages/runtime-core/src/project/native/tests.rs b/packages/runtime-core/src/project/native/tests.rs index 0723bb3fb..ff206e588 100644 --- a/packages/runtime-core/src/project/native/tests.rs +++ b/packages/runtime-core/src/project/native/tests.rs @@ -1063,3 +1063,49 @@ fn persistent_host_capability_is_exact_ephemeral_and_keeps_standalone_and_mixed_ ); } } + +#[test] +fn generated_runtime_metadata_is_public_environment_below_managed_and_authored_values() { + let mut project = basic(); + project["services"]["web"]["environment"] = + json!({"HACK_DEV_URL":{"literal":"https://authored.example"}}); + let metadata = json!({"web":{"HACK_BRANCH":{"scope":"web","secret":true}}}); + let mut wire: Value = serde_json::from_slice(&request(&project, metadata.clone())).unwrap(); + wire["env_metadata"]["runtime"] = json!({"web":{ + "HACK_BRANCH":"generated-branch", + "HACK_DEV_HOST":"feature.fixture.hack.local", + "HACK_DEV_URL":"https://feature.fixture.hack.local:8443", + "HACK_SERVICE_NAME":"web" + }}); + let bytes = serde_json::to_vec(&wire).unwrap(); + let values = BTreeMap::from([( + "web".into(), + BTreeMap::from([("HACK_BRANCH".into(), "synthetic-private-branch".into())]), + )]); + let inputs = compile(CompileOptions { + request: &bytes, + profiles: &[], + managed_values: &values, + }) + .unwrap(); + assert_eq!( + inputs.workloads["web"].environment, + BTreeMap::from([ + ("HACK_DEV_HOST".into(), "feature.fixture.hack.local".into()), + ("HACK_DEV_URL".into(), "https://authored.example".into()), + ("HACK_SERVICE_NAME".into(), "web".into()), + ]) + ); + assert_eq!( + inputs.managed_environment["web"], + BTreeMap::from([("HACK_BRANCH".into(), "synthetic-private-branch".into())]) + ); + // Generated values are part of the reviewed public policy identity. + let reviewed = review(&bytes, &[]).unwrap(); + let without = review(&request(&project, metadata), &[]).unwrap(); + assert_eq!(reviewed.semantic_hash, without.semantic_hash); + assert_ne!( + reviewed.environment_policy_hash, + without.environment_policy_hash + ); +} diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index 168845512..4d221a373 100644 --- a/plugins/hack/rules/hack.mdc +++ b/plugins/hack/rules/hack.mdc @@ -14,7 +14,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary diff --git a/plugins/hack/skills/hack-branches/SKILL.md b/plugins/hack/skills/hack-branches/SKILL.md index c508938b6..9223b872a 100644 --- a/plugins/hack/skills/hack-branches/SKILL.md +++ b/plugins/hack/skills/hack-branches/SKILL.md @@ -14,7 +14,7 @@ description: > - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Branch aliases @@ -35,4 +35,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-cli/SKILL.md b/plugins/hack/skills/hack-cli/SKILL.md index 069562770..77243c01e 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## Product boundary @@ -162,7 +162,7 @@ Use `hack` as the primary interface for local-first development. - Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch. - Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`). - Linked worktrees pick a branch instance automatically (see Linked git worktrees). -- Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. +- Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks. ## Project targeting diff --git a/plugins/hack/skills/hack-diagnose/SKILL.md b/plugins/hack/skills/hack-diagnose/SKILL.md index 5eb98da54..1cc9297ec 100644 --- a/plugins/hack/skills/hack-diagnose/SKILL.md +++ b/plugins/hack/skills/hack-diagnose/SKILL.md @@ -37,7 +37,7 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). ## References diff --git a/plugins/hack/skills/hack-env/SKILL.md b/plugins/hack/skills/hack-env/SKILL.md index d804b55e1..db99abd99 100644 --- a/plugins/hack/skills/hack-env/SKILL.md +++ b/plugins/hack/skills/hack-env/SKILL.md @@ -34,4 +34,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `8f9bff2d631a` (version alone is not a freshness guarantee). diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 7ef0b7ff5..beb584031 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -293,7 +293,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "Use a branch instance when you need two versions running at once (PR review, experiments, migrations) or want to keep a stable environment while testing another branch.", "Target one with `--branch ` on up/open/logs/down (for example: `hack up --branch --detach`).", "Linked worktrees pick a branch instance automatically (see Linked git worktrees).", - "Containers in Compose-backed and native image projects receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective Caddy routes; authored `.hack/hack.project.json` projects do not receive them yet. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", + "Containers in Compose-file and authored `.hack/hack.project.json` projects, on the Compose and native backends, receive `HACK_RUNTIME_METADATA` plus `HACK_DEV_URL`, `HACK_ALIAS_URL`, and current-service URL fields derived from effective routes; explicit project env and authored env values win. Use Compose DNS for internal traffic and this metadata for browser-facing links, OAuth callbacks, and webhooks.", ], }, { diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index fa8db2b83..c6f9b3559 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -116,6 +116,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "eba974f66328605864507beb", // next: focused plugin skills + plugin-first agent setup "cf91ca873a00ad4743198e11", // next: plugin-first agent setup + refresh-only setup sync "0e99791aa0edfd1895e31748", // next: runtime metadata backend scope + refresh-only setup sync + "124ca278acd4be97747bac9b", // next: runtime metadata for authored projects ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 @@ -154,6 +155,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "270cd2b3fdcc325e04c76d43", // next: focused plugin skills + plugin-first agent setup "92ca4aa8f28ba1c93093c04a", // next: plugin-first agent setup + refresh-only setup sync "0b82281933af514de8a1b8b8", // next: runtime metadata backend scope + refresh-only setup sync + "05f095e17267519ad3660da5", // next: runtime metadata for authored projects ]), }; diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 5dfd1fdfe..fde8a0dce 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "f4120f477794"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "8f9bff2d631a"; diff --git a/src/backends/native-authored-project-restart.ts b/src/backends/native-authored-project-restart.ts index b653bd464..a73cccd27 100644 --- a/src/backends/native-authored-project-restart.ts +++ b/src/backends/native-authored-project-restart.ts @@ -68,6 +68,8 @@ export type NativeAuthoredProjectRestartOptions = { readonly run: string; readonly profiles?: readonly string[]; readonly overlay?: string | null; + /** Native HTTPS port for the start half's generated runtime metadata URLs. */ + readonly httpsPort?: ServeOptions["httpsPort"]; /** Budget of each half; the stop half does not lend time to the start half. */ readonly startupTimeoutMs: number; readonly signal?: AbortSignal; @@ -161,6 +163,7 @@ export async function restartNativeAuthoredProject( run: opts.run, profiles: opts.profiles, overlay: opts.overlay, + httpsPort: opts.httpsPort, startupTimeoutMs: opts.startupTimeoutMs, signal: opts.signal, forceSignal: opts.forceSignal, diff --git a/src/backends/native-authored-project-start.ts b/src/backends/native-authored-project-start.ts index 8550afb42..58aaa02e1 100644 --- a/src/backends/native-authored-project-start.ts +++ b/src/backends/native-authored-project-start.ts @@ -153,6 +153,11 @@ type Options = { readonly run: string; readonly profiles?: readonly string[]; readonly overlay?: string | null; + /** + * Selected native HTTPS frontend port for the generated runtime host metadata + * URLs; omitted, null or 443 keeps them portless, as for native image projects. + */ + readonly httpsPort?: number | null; readonly startupTimeoutMs: number; readonly signal?: AbortSignal; readonly forceSignal?: AbortSignal; @@ -719,6 +724,13 @@ export async function serveNativeAuthoredProject( !isAbsolute(opts.runtime.binary) || opts.runtime.home !== opts.scope.nativeHome || !RUN.test(opts.run) || + (opts.httpsPort !== undefined && + opts.httpsPort !== null && + !( + Number.isSafeInteger(opts.httpsPort) && + opts.httpsPort >= 1 && + opts.httpsPort <= 65_535 + )) || !Number.isSafeInteger(opts.startupTimeoutMs) || opts.startupTimeoutMs < 1 || opts.startupTimeoutMs > 300_000 || @@ -803,6 +815,10 @@ export async function serveNativeAuthoredProject( profiles: opts.profiles, explicitOverlay: opts.overlay, compilerBranch: opts.scope.branch ?? undefined, + runtimeMetadata: { + branch: opts.scope.branch, + httpsPort: opts.httpsPort ?? null, + }, signal, }); let inputs = await acquire(controller.signal); diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 9748913fd..06365e026 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -25,6 +25,7 @@ import { NativeAuthoredProjectStartError, serveNativeAuthoredProject, } from "../backends/native-authored-project-start.ts"; +import { parseNativeHttpsSelection } from "../backends/native-project-start.ts"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; import { CliUsageError } from "../cli/command.ts"; import { display } from "../ui/display.ts"; @@ -406,6 +407,22 @@ async function runStop(input: LifecycleInput): Promise { return 0; } +/** + * The native HTTPS frontend port selected the same way native image projects + * select it, so authored runtime host metadata URLs carry the same port. + */ +function nativeHttpsPort( + env: Readonly> +): number | null { + try { + return parseNativeHttpsSelection(env)?.httpsPort ?? null; + } catch { + throw new CliUsageError( + "Native HTTPS selection requires HACK_NATIVE_CADDY_BINARY, HACK_NATIVE_CADDY_SHA256 and HACK_NATIVE_HTTPS_PORT together; values omitted." + ); + } +} + /** Fresh foreground selection shared by up and the start half of restart. */ function startupSelection( input: LifecycleInput @@ -416,6 +433,7 @@ function startupSelection( run: randomBytes(16).toString("hex"), profiles: input.options.profiles, overlay: input.options.overlay, + httpsPort: nativeHttpsPort(input.env), startupTimeoutMs: input.startupTimeoutMs, signal: input.signal, forceSignal: input.forceSignal, @@ -552,12 +570,19 @@ export async function tryNativeAuthoredCommand( HACK_NATIVE_BINARY: sourceEnv.HACK_NATIVE_BINARY, HACK_NATIVE_HOME: sourceEnv.HACK_NATIVE_HOME, HACK_COMPOSE_STARTUP_TIMEOUT_MS: sourceEnv.HACK_COMPOSE_STARTUP_TIMEOUT_MS, + HACK_NATIVE_CADDY_BINARY: sourceEnv.HACK_NATIVE_CADDY_BINARY, + HACK_NATIVE_CADDY_SHA256: sourceEnv.HACK_NATIVE_CADDY_SHA256, + HACK_NATIVE_HTTPS_PORT: sourceEnv.HACK_NATIVE_HTTPS_PORT, }; if (env.HACK_RUNTIME_BACKEND !== "native") { return null; } const options = captureCommandOptions(opts.options); const mode = commandMode(options); + if (mode === "start" || mode === "restart") { + // Refuse a partial HTTPS selection before any input or runtime work. + nativeHttpsPort(env); + } let runtime: ReturnType; try { runtime = resolveNativeRuntimeSelection(env); diff --git a/src/lib/native-authored-runtime-metadata.ts b/src/lib/native-authored-runtime-metadata.ts new file mode 100644 index 000000000..7e728ee24 --- /dev/null +++ b/src/lib/native-authored-runtime-metadata.ts @@ -0,0 +1,178 @@ +import { DEFAULT_NEW_PROJECT_TLD } from "../constants.ts"; +import type { NativeDeclaredWorkloads } from "./native-env-plan-protocol.ts"; +import type { NativeRoutingResolution } from "./native-routing-plan-protocol.ts"; +import { + buildEffectiveRuntimeHostMetadataEnvironments, + RUNTIME_HOST_METADATA_KEY_NAMES, + type RuntimeHostMetadataService, + runtimeHostMetadataComposeProject, +} from "./runtime-host-metadata.ts"; + +/** Instance identity the command owner selects; hosts come from compiler routing. */ +export type NativeAuthoredRuntimeMetadataSelection = { + /** Selected instance branch; a compiler routing resolution's branch takes precedence. */ + readonly branch: string | null; + /** + * Actual Compose project for the Compose backend. Omitted reports + * `` or `--`, the same name native image projects report. + */ + readonly composeProject?: string; + /** Public HTTPS ingress port; null or 443 keeps URLs portless. */ + readonly httpsPort: number | null; +}; + +/** + * The Compose backend's selection: the selected `--branch` instance, the + * instance's actual Compose project, and portless URLs behind shared ingress. + * Every authored Compose planner (command inputs and file material sources) + * uses this one mapping so their plans stay byte-identical. + */ +export function nativeComposeRuntimeMetadataSelection(identity: { + readonly instance: string | null; + readonly composeProject: string; +}): NativeAuthoredRuntimeMetadataSelection { + return { + branch: identity.instance, + composeProject: identity.composeProject, + httpsPort: null, + }; +} + +/** Public generated values per declared workload. Never secret and never managed. */ +export type NativeRuntimeMetadataValues = Readonly< + Record>> +>; + +const RESERVED = new Set(RUNTIME_HOST_METADATA_KEY_NAMES); + +function originHost(origin: string | undefined): string | null { + if (origin === undefined) { + return null; + } + try { + const host = new URL(origin).hostname; + return host.length > 0 ? host : null; + } catch { + return null; + } +} + +/** + * Public route hostnames per service: routes in name order, each route origin + * before its aliases (in alias name order), so the first service URL is the + * development origin, as in a Compose route label that lists the dev host first. + */ +function serviceHosts( + routing: NativeRoutingResolution | undefined +): Map { + const hosts = new Map(); + if (!routing) { + return hosts; + } + for (const name of Object.keys(routing.routes).sort()) { + const route = routing.routes[name]; + if (!route) { + continue; + } + const selected = hosts.get(route.service) ?? []; + const aliases = Object.keys(route.aliases) + .sort() + .map((alias) => route.aliases[alias]); + for (const origin of [route.origin, ...aliases]) { + const host = originHost(origin); + if (host !== null) { + selected.push(host); + } + } + hosts.set(route.service, selected); + } + return hosts; +} + +/** + * Hack-generated runtime host metadata for an authored `.hack/hack.project.json` + * project, computed by the same shared builder Compose-backed and native image + * projects use. The compiler's routing resolution supplies the effective + * (already branch-applied) development, OAuth alias and service hosts; without + * one, the development host is the compiler's generated `.hack.local` + * origin. Every declared workload receives the full key set: the compiler binds + * these values below managed and authored env, so explicit values win there. + */ +export function nativeAuthoredRuntimeMetadata(opts: { + readonly projectName: string; + readonly declaredWorkloads: NativeDeclaredWorkloads; + readonly routing: NativeRoutingResolution | undefined; + readonly selection: NativeAuthoredRuntimeMetadataSelection; +}): NativeRuntimeMetadataValues { + const { routing, selection } = opts; + const branch = routing ? (routing.branch ?? null) : selection.branch; + const effectiveDevHost = + originHost(routing?.project_origin) ?? + [branch, opts.projectName, DEFAULT_NEW_PROJECT_TLD] + .filter((part): part is string => typeof part === "string") + .join("."); + const effectiveAliasHost = + routing?.oauth_alias === null || routing?.oauth_alias === undefined + ? null + : originHost(routing.aliases[routing.oauth_alias]); + const hosts = serviceHosts(routing); + const services = new Map( + Object.keys(opts.declaredWorkloads).map((name) => [ + name, + { hosts: hosts.get(name) ?? [], declaredEnvironmentKeys: new Set() }, + ]) + ); + const environments = buildEffectiveRuntimeHostMetadataEnvironments({ + services, + branch, + effectiveDevHost, + effectiveAliasHost, + composeProject: + selection.composeProject ?? + runtimeHostMetadataComposeProject({ + projectName: opts.projectName, + branch, + }), + httpsPort: selection.httpsPort, + }); + return Object.fromEntries(environments); +} + +/** Closed wire projection: declared workloads, reserved keys and plain text values only. */ +export function parseNativeRuntimeMetadataValues( + value: unknown, + workloadName: (name: string) => boolean +): NativeRuntimeMetadataValues | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const output: Record> = {}; + for (const [name, entries] of Object.entries(value)) { + if ( + !workloadName(name) || + typeof entries !== "object" || + entries === null || + Array.isArray(entries) + ) { + return null; + } + const projected: Record = {}; + for (const [key, entry] of Object.entries(entries)) { + if ( + !RESERVED.has(key) || + typeof entry !== "string" || + entry.includes("\0") + ) { + return null; + } + projected[key] = entry; + } + output[name] = projected; + } + return output; +} + +/** Whether `key` is a reserved generated runtime metadata key. */ +export function isNativeRuntimeMetadataKey(key: string): boolean { + return RESERVED.has(key); +} diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 02e674912..224e0f359 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -12,6 +12,7 @@ import { import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import { isRecord } from "./guards.ts"; import { tryNativeAuthoredCommand } from "./native-authored-command.ts"; +import { nativeComposeRuntimeMetadataSelection } from "./native-authored-runtime-metadata.ts"; import { tryLegacyComposeAdoptedCommand } from "./native-compose-adoption-command.ts"; import { assertNativeComposeAfterInputsUnchanged, @@ -63,6 +64,7 @@ import { runNativeComposeOwnedFileChild, } from "./native-compose-file-command.ts"; import { + expectedNativeComposeProject, type NativeComposeGeneration, NativeComposeGenerationError, type NativeComposeGenerationStore, @@ -1874,11 +1876,22 @@ async function prepareCommand(opts: { readonly signal: AbortSignal; }): Promise { const { options, projectRoot, signal } = opts; + // Generated runtime host metadata reports the instance's actual Compose project. + // The opened store's identity is authoritative and is compared below. + const composeProject = await expectedNativeComposeProject({ + projectRoot, + instance: options.instance ?? null, + }); + const runtimeMetadata = nativeComposeRuntimeMetadataSelection({ + instance: options.instance ?? null, + composeProject, + }); const acquire = () => acquireNativeComposeFilePlanningInputs({ projectRoot, profiles: options.profiles, explicitOverlay: options.overlay, + runtimeMetadata, signal, }); let inputs = await acquire(); @@ -1923,6 +1936,9 @@ async function prepareCommand(opts: { mode: "prepare", }); try { + if (store.identity.composeProject !== composeProject) { + return invalid(); + } return await store.withMutation(async (mutation) => { const current = await store.loadCurrent(); assertStartupAvailable(current); @@ -1999,6 +2015,7 @@ async function prepareCommand(opts: { inputs, profiles: options.profiles, explicitOverlay: options.overlay, + runtimeMetadata, signal, }) : null; diff --git a/src/lib/native-compose-file-command.ts b/src/lib/native-compose-file-command.ts index 59e3c1b0d..042d2e1de 100644 --- a/src/lib/native-compose-file-command.ts +++ b/src/lib/native-compose-file-command.ts @@ -3,6 +3,7 @@ import { HackCliError } from "./cli-result.ts"; import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; +import type { NativeAuthoredRuntimeMetadataSelection } from "./native-authored-runtime-metadata.ts"; import { nativeComposeEffectReason, retainNativeComposeEffectRefusal, @@ -270,6 +271,8 @@ export async function prepareNativeComposeCommandFiles(opts: { readonly inputs: NativeComposeExecutionInputs; readonly profiles?: readonly string[]; readonly explicitOverlay?: string | null; + /** The selection `inputs` planned runtime host metadata with, if any. */ + readonly runtimeMetadata?: NativeAuthoredRuntimeMetadataSelection; readonly signal: AbortSignal; }) { const { mutation, store, reservation, inputs, signal } = opts; @@ -285,6 +288,7 @@ export async function prepareNativeComposeCommandFiles(opts: { reservation, profiles, explicitOverlay, + runtimeMetadata: opts.runtimeMetadata, signal, }); const owner = createNativeComposeFileOwner({ diff --git a/src/lib/native-compose-file-sources.ts b/src/lib/native-compose-file-sources.ts index 691880a8b..9d6dc15dd 100644 --- a/src/lib/native-compose-file-sources.ts +++ b/src/lib/native-compose-file-sources.ts @@ -2,6 +2,10 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import type { FileBinding } from "../../packages/config-compiler/generated/native-config.ts"; import { deepFreeze } from "./guards.ts"; +import { + type NativeAuthoredRuntimeMetadataSelection, + nativeAuthoredRuntimeMetadata, +} from "./native-authored-runtime-metadata.ts"; import { type HeldNativeComposeFile, holdNativeComposeFile, @@ -187,6 +191,33 @@ async function acquireMembers(opts: { } } } +function runtimeValues( + result: { + readonly plan: Readonly>; + readonly declared_workloads?: Parameters< + typeof nativeAuthoredRuntimeMetadata + >[0]["declaredWorkloads"]; + readonly routing_resolution?: Parameters< + typeof nativeAuthoredRuntimeMetadata + >[0]["routing"]; + }, + selection: NativeAuthoredRuntimeMetadataSelection | undefined +) { + if (selection === undefined) { + return undefined; + } + const declared = result.declared_workloads; + const name = result.plan.name; + if (!declared || typeof name !== "string") { + return refuseNativeComposeFile(); + } + return nativeAuthoredRuntimeMetadata({ + projectName: name, + declaredWorkloads: declared, + routing: result.routing_resolution, + selection, + }); +} /** * Actual acquisition, not a caller-provided plan/value resolver. It anchors the current * checkout and managed baseline, preserves empty/binary bytes and holds source FDs. @@ -198,6 +229,8 @@ export async function acquireNativeComposeFileSources(opts: { readonly profiles?: readonly string[]; readonly explicitOverlay?: string | null; readonly explicitDomain?: string; + /** The selection the command inputs planned runtime host metadata with. */ + readonly runtimeMetadata?: NativeAuthoredRuntimeMetadataSelection; readonly signal?: AbortSignal; }): Promise { const authority = opts.authority; @@ -257,9 +290,13 @@ export async function acquireNativeComposeFileSources(opts: { } const envSelection = selectionForEnv(prepared); const env = await acquireProjectEnvForNativeExecution(envSelection); + // The command inputs' runtime metadata selection, so both plans stay + // byte-identical; the delivery owner compares them before any effect. + const runtime = runtimeValues(prepared.result, opts.runtimeMetadata); const planned = await planPreparedNativeProject({ prepared, metadata: env.metadata, + runtime, signal, }); if ( diff --git a/src/lib/native-compose-generation.ts b/src/lib/native-compose-generation.ts index 727c59eaa..5bf1e1874 100644 --- a/src/lib/native-compose-generation.ts +++ b/src/lib/native-compose-generation.ts @@ -583,6 +583,44 @@ type Manifest = { function hash(text: string): string { return createHash("sha256").update(text).digest("hex"); } +function nativeComposeInstanceId(opts: { + readonly checkoutRoot: string; + readonly dev: number; + readonly ino: number; + readonly instance: string | null; +}): string { + return hash( + JSON.stringify([opts.checkoutRoot, opts.dev, opts.ino, opts.instance]) + ); +} +function nativeComposeProjectName(instanceId: string): string { + return `hack-nc-${instanceId.slice(0, 32)}`; +} + +/** + * The Compose project name a store opened for this checkout and instance reports. + * Read-only (one stat); it creates no state and grants no ownership. The opened + * store's identity stays authoritative: callers that use this before opening must + * compare it with `store.identity.composeProject` and refuse on a mismatch. + */ +export async function expectedNativeComposeProject(opts: { + readonly projectRoot: string; + readonly instance: string | null; +}): Promise { + const checkoutRoot = resolve(opts.projectRoot); + const info = await lstat(checkoutRoot).catch(() => refuse()); + if (!info.isDirectory()) { + refuse(); + } + return nativeComposeProjectName( + nativeComposeInstanceId({ + checkoutRoot, + dev: info.dev, + ino: info.ino, + instance: opts.instance, + }) + ); +} function identityMatches(left: unknown, right: NativeComposeIdentity): boolean { return ( isRecord(left) && @@ -1361,14 +1399,12 @@ export async function openNativeComposeGenerationStore(opts: { directories.push(await ownedDirectory(root)); const ignorePath = join(root, ".gitignore"); const ignore = await privateIgnore(ignorePath, opts.mode !== "saved"); - const instanceId = hash( - JSON.stringify([ - checkoutRoot, - checkout.info.dev, - checkout.info.ino, - opts.instance, - ]) - ); + const instanceId = nativeComposeInstanceId({ + checkoutRoot, + dev: checkout.info.dev, + ino: checkout.info.ino, + instance: opts.instance, + }); const instanceRoot = join(root, instanceId); directories.push(await ownedDirectory(instanceRoot)); const generationsRoot = join(instanceRoot, "generations"); @@ -1432,7 +1468,7 @@ export async function openNativeComposeGenerationStore(opts: { repositoryRoot, instance: opts.instance, instanceId, - composeProject: `hack-nc-${instanceId.slice(0, 32)}`, + composeProject: nativeComposeProjectName(instanceId), ownerToken: token(), }; value = { @@ -1459,7 +1495,7 @@ export async function openNativeComposeGenerationStore(opts: { repositoryRoot, instance: opts.instance, instanceId, - composeProject: `hack-nc-${instanceId.slice(0, 32)}`, + composeProject: nativeComposeProjectName(instanceId), ownerToken: value.identity.ownerToken, }); parseReceipt(value, identity, checkoutAnchor); diff --git a/src/lib/native-compose-renderer.ts b/src/lib/native-compose-renderer.ts index 2360c76cc..87e27a2f1 100644 --- a/src/lib/native-compose-renderer.ts +++ b/src/lib/native-compose-renderer.ts @@ -6,6 +6,7 @@ import type { Workload, } from "../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "./guards.ts"; +import { isNativeRuntimeMetadataKey } from "./native-authored-runtime-metadata.ts"; import { type NativeComposeFileProjection, nativeComposeFileProjectionMatches, @@ -1038,6 +1039,11 @@ export function resolveNativeComposeEnvironment(opts: { } else if (binding.kind === "endpoint") { assert(Object.hasOwn(directives, key), "E_COMPOSE_ENV_MISMATCH"); entries.push([key, opts.endpointValue(binding)]); + } else if (binding.kind === "runtime") { + entries.push([ + key, + runtimeValue({ key, binding, directives, values: opts.values }), + ]); } else { const authored = directives[key]; assert( @@ -1053,6 +1059,23 @@ export function resolveNativeComposeEnvironment(opts: { return Object.fromEntries(entries); } +/** Generated public metadata never shadows an authored directive or a managed value. */ +function runtimeValue(opts: { + readonly key: string; + readonly binding: Extract; + readonly directives: Record; + readonly values?: Readonly>; +}): string { + assert( + isNativeRuntimeMetadataKey(opts.key) && + !Object.hasOwn(opts.directives, opts.key) && + (opts.values === undefined || !Object.hasOwn(opts.values, opts.key)), + "E_COMPOSE_ENV_MISMATCH" + ); + assert(text(opts.binding.value), "E_COMPOSE_ENV"); + return opts.binding.value; +} + function verifyBaseline( key: string, directive: Record, diff --git a/src/lib/native-env-plan-protocol.ts b/src/lib/native-env-plan-protocol.ts index 6bcda60bb..e90e71d61 100644 --- a/src/lib/native-env-plan-protocol.ts +++ b/src/lib/native-env-plan-protocol.ts @@ -6,6 +6,11 @@ import type { WorkloadKind, } from "../../packages/config-compiler/generated/native-config.ts"; import { isRecord } from "./guards.ts"; +import { + isNativeRuntimeMetadataKey, + type NativeRuntimeMetadataValues, + parseNativeRuntimeMetadataValues, +} from "./native-authored-runtime-metadata.ts"; import type { NativeConfigDiagnostic } from "./native-config-compiler.ts"; import { parseNativeEndpointBinding } from "./native-endpoint-plan-protocol.ts"; import { @@ -24,13 +29,15 @@ const OVERLAY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export type NativeDeclaredWorkloads = Readonly>; export type NativeManagedEntry = Readonly; export type NativeEnvMetadata = Readonly< - Omit + Omit > & { readonly workloads: Readonly< Record>> >; readonly inactive_scopes: readonly string[]; readonly host?: NativeHostMetadata; + /** Hack-generated public runtime host metadata; never managed or secret values. */ + readonly runtime?: NativeRuntimeMetadataValues; }; export type NativeEnvBinding = Readonly; export type NativeEnvironmentPlan = Readonly< @@ -86,6 +93,7 @@ export function parseNativeEnvMetadata( "workloads", "inactive_scopes", "host", + "runtime", ]) ) || value.metadata_version !== 1 || @@ -113,6 +121,14 @@ export function parseNativeEnvMetadata( if (host === null) { return null; } + const runtime = Object.hasOwn(value, "runtime") + ? parseNativeRuntimeMetadataValues(value.runtime, (name) => + Object.hasOwn(workloads, name) + ) + : undefined; + if (runtime === null) { + return null; + } return { metadata_version: 1, overlay: value.overlay, @@ -120,6 +136,7 @@ export function parseNativeEnvMetadata( workloads, inactive_scopes: inactiveScopes, ...(host === undefined ? {} : { host }), + ...(runtime === undefined ? {} : { runtime }), }; } @@ -152,6 +169,13 @@ function parseWorkloadMetadata( return workloads; } +/** Host process reports never carry generated container runtime metadata. */ +function parseHostBinding(value: unknown): NativeEnvBinding | null { + return isRecord(value) && value.kind === "runtime" + ? null + : parseBinding(value); +} + function parseBinding(value: unknown): NativeEnvBinding | null { if (!isRecord(value)) { return null; @@ -182,6 +206,14 @@ function parseBinding(value: unknown): NativeEnvBinding | null { ) { return { kind: value.kind, value: value.value }; } + if ( + value.kind === "runtime" && + hasOnly(value, ["kind", "value"]) && + typeof value.value === "string" && + !value.value.includes("\0") + ) { + return { kind: "runtime", value: value.value }; + } return null; } @@ -223,7 +255,10 @@ export function parseNativeEnvironmentPlan(opts: { const bindings: Record = {}; for (const [key, entry] of Object.entries(entries)) { const binding = parseBinding(entry); - if (!(AUTHORED_KEY.test(key) && binding)) { + if ( + !(AUTHORED_KEY.test(key) && binding) || + (binding.kind === "runtime" && !isNativeRuntimeMetadataKey(key)) + ) { return null; } // Authored destinations may include __proto__; define an own data key. @@ -239,7 +274,10 @@ export function parseNativeEnvironmentPlan(opts: { const warnings = value.warnings.map(opts.parseDiagnostic); const diagnostics = value.diagnostics.map(opts.parseDiagnostic); const host = Object.hasOwn(value, "host") - ? parseNativeHostReports({ value: value.host, parseBinding }) + ? parseNativeHostReports({ + value: value.host, + parseBinding: parseHostBinding, + }) : undefined; if ( host === null || diff --git a/src/lib/native-execution-inputs.ts b/src/lib/native-execution-inputs.ts index 82f4a8b1a..730b53e9c 100644 --- a/src/lib/native-execution-inputs.ts +++ b/src/lib/native-execution-inputs.ts @@ -1,4 +1,8 @@ import { createHash } from "node:crypto"; +import { + type NativeAuthoredRuntimeMetadataSelection, + nativeAuthoredRuntimeMetadata, +} from "./native-authored-runtime-metadata.ts"; import { assertNativeComposeFileSubset } from "./native-compose-file-subset.ts"; import { NativeConfigCompilerError } from "./native-config-compiler.ts"; import { authoredFilePlanningRequired } from "./native-file-plan-protocol.ts"; @@ -78,9 +82,21 @@ type NativeExecutionInputSelection = { readonly explicitOverlay?: string | null; readonly explicitDomain?: string; readonly compilerBranch?: string; + /** + * Instance identity for Hack-generated public runtime host metadata. When + * present, every declared workload's plan carries the shared metadata keys as + * public `runtime` bindings below managed and authored env. + */ + readonly runtimeMetadata?: NativeAuthoredRuntimeMetadataSelection; readonly signal?: AbortSignal; }; +function projectName(plan: Readonly>): string { + return typeof plan.name === "string" && plan.name.length > 0 + ? plan.name + : refused(); +} + /** Graph inputs always refuse raw file intent before environment acquisition. */ export function acquireNativeExecutionInputs( opts: NativeExecutionInputSelection @@ -147,14 +163,25 @@ async function acquireInputs(input: { signal, }; const env = await acquireProjectEnvForNativeExecution(envSelection); + const runtime = + opts.runtimeMetadata === undefined + ? undefined + : nativeAuthoredRuntimeMetadata({ + projectName: projectName(resolved.plan), + declaredWorkloads: declared, + routing: resolved.routing_resolution, + selection: opts.runtimeMetadata, + }); const metadata = nativeEnvironmentMetadata({ metadata: env.metadata, declaredWorkloads: declared, + runtime, }); freezePlan(metadata); const planned = await planPreparedNativeProject({ prepared, metadata: env.metadata, + runtime, signal, }); if ( diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts index 41f893061..d98d98fb4 100644 --- a/src/lib/native-project-validation.ts +++ b/src/lib/native-project-validation.ts @@ -1,4 +1,5 @@ import { isRecord } from "./guards.ts"; +import type { NativeRuntimeMetadataValues } from "./native-authored-runtime-metadata.ts"; import { compileNativeConfig, NativeConfigCompilerError, @@ -98,6 +99,8 @@ export async function planNativeProject( export async function planPreparedNativeProject(opts: { readonly prepared: NativePreparedProject; readonly metadata: NativeProjectEnvMetadata; + /** Hack-generated public runtime host metadata; never managed or secret values. */ + readonly runtime?: NativeRuntimeMetadataValues; readonly signal?: AbortSignal; }): Promise { const { prepared, metadata } = opts; @@ -121,6 +124,7 @@ export async function planPreparedNativeProject(opts: { envMetadata: nativeEnvironmentMetadata({ metadata, declaredWorkloads: declared, + runtime: opts.runtime, }), }); if ( @@ -145,6 +149,8 @@ export async function planPreparedNativeProject(opts: { export function nativeEnvironmentMetadata(opts: { readonly metadata: NativeProjectEnvMetadata; readonly declaredWorkloads: NativeDeclaredWorkloads; + /** Generated public runtime metadata; omitted keeps the prior wire unchanged. */ + readonly runtime?: NativeRuntimeMetadataValues; }): NativeEnvMetadata { const { metadata, declaredWorkloads } = opts; return { @@ -161,6 +167,7 @@ export function nativeEnvironmentMetadata(opts: { ...(metadata.hostMetadata === undefined ? {} : { host: metadata.hostMetadata }), + ...(opts.runtime === undefined ? {} : { runtime: opts.runtime }), }; } diff --git a/src/lib/runtime-host-metadata.ts b/src/lib/runtime-host-metadata.ts index 140447c8a..ef66162c3 100644 --- a/src/lib/runtime-host-metadata.ts +++ b/src/lib/runtime-host-metadata.ts @@ -145,6 +145,35 @@ export function buildRuntimeHostMetadataEnvironments(opts: { baseHosts, }) : null; + return buildEffectiveRuntimeHostMetadataEnvironments({ + services: opts.services, + branch: opts.branch, + effectiveDevHost, + effectiveAliasHost, + composeProject: opts.composeProject, + httpsPort: opts.httpsPort, + }); +} + +/** + * The shared metadata document and key set for callers whose instance hosts are + * already effective (branch-applied), such as authored projects whose compiler + * resolves branch origins itself. {@link buildRuntimeHostMetadataEnvironments} + * delegates here after applying the branch, so every backend emits identical keys, + * values and document shape for the same effective identity. + */ +export function buildEffectiveRuntimeHostMetadataEnvironments(opts: { + readonly services: ReadonlyMap; + readonly branch: string | null; + /** Effective development host, already branch-applied. */ + readonly effectiveDevHost: string; + /** Effective OAuth alias host, already branch-applied, or null when disabled. */ + readonly effectiveAliasHost: string | null; + readonly composeProject: string; + /** Public HTTPS ingress port; omitted, null or 443 keeps URLs portless. */ + readonly httpsPort?: number | null; +}): Map> { + const { effectiveDevHost, effectiveAliasHost } = opts; const url = (host: string) => runtimeHostUrl({ host, httpsPort: opts.httpsPort }); const publicServices: Record = diff --git a/tests/native-authored-command.test.ts b/tests/native-authored-command.test.ts index 6bef00309..cbb484264 100644 --- a/tests/native-authored-command.test.ts +++ b/tests/native-authored-command.test.ts @@ -8,6 +8,7 @@ import { NativeAuthoredProjectStartError, type serveNativeAuthoredProject, } from "../src/backends/native-authored-project-start.ts"; +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"; @@ -718,3 +719,55 @@ macTest( expect(await readdir(join(selected.root, ".hack"))).toEqual([]); } ); + +macTest( + "foreground up and restart carry the selected native HTTPS port for runtime metadata", + async () => { + const selected = await fixture(); + const https = { + HACK_NATIVE_CADDY_BINARY: "/opt/native/caddy", + HACK_NATIVE_CADDY_SHA256: "a".repeat(64), + HACK_NATIVE_HTTPS_PORT: "8443", + }; + const ports: (number | null | undefined)[] = []; + for (const [operation, env] of [ + ["up", { ...selected.env, ...https }], + ["up", selected.env], + ["restart", { ...selected.env, ...https }], + ] as const) { + expect( + await tryNativeAuthoredCommand({ + ...selected, + options: { ...selected.options, operation }, + env, + serve: (startup) => { + ports.push(startup.httpsPort); + return Promise.resolve(0); + }, + stop: () => Promise.resolve(), + }) + ).toBe(0); + } + expect(ports).toEqual([8443, null, 8443]); + } +); + +macTest( + "a partial native HTTPS selection refuses before input or owner work", + async () => { + const selected = await fixture(); + let calls = 0; + const error: unknown = await tryNativeAuthoredCommand({ + ...selected, + env: { ...selected.env, HACK_NATIVE_HTTPS_PORT: "8443" }, + serve: () => { + calls++; + return Promise.resolve(0); + }, + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(CliUsageError); + expect(String(error)).not.toContain("8443"); + expect(calls).toBe(0); + expect(await readdir(join(selected.root, ".hack"))).toEqual([]); + } +); diff --git a/tests/native-authored-project-start.test.ts b/tests/native-authored-project-start.test.ts index 2ed1462e4..9b4a51feb 100644 --- a/tests/native-authored-project-start.test.ts +++ b/tests/native-authored-project-start.test.ts @@ -380,6 +380,7 @@ console.log(JSON.stringify(result)); const journal = join(root, "journal.json"); const calls = join(root, "calls.jsonl"); const delivery = join(root, "delivery.json"); + const sources = join(root, "sources.jsonl"); const ownerPid = join(root, "owner.pid"); // Each stand-in owner appends its PID; a detached owner leads its own group. settlement.record({ @@ -401,6 +402,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ const planning=action==='plan'||action==='frontend-plan'; if(!planning)await appendFile(${JSON.stringify(ownerPid)},process.pid+'\\n'); const path=args[args.indexOf('--source-file')+1];const input=JSON.parse(await readFile(path,'utf8')); + if(planning)await appendFile(${JSON.stringify(sources)},JSON.stringify(input.env_metadata)+'\\n'); const digest=createHash('sha256'); if(input.branch!==null)digest.update('hack-native-branch-namespace-v1\\0'); digest.update(input.project);if(input.branch!==null)digest.update('\\0').update(input.branch); @@ -485,6 +487,7 @@ if(['plan','serve','frontend-plan','frontend-serve'].includes(action)){ runtime: { binary, home: nativeHome }, calls, delivery, + sources, }; } @@ -1513,3 +1516,41 @@ macTest( }, 30_000 ); + +for (const [httpsPort, branch, url] of [ + [8443, "main", "https://main.fixture.hack.local:8443"], + [undefined, "main", "https://main.fixture.hack.local"], + [8443, null, "https://fixture.hack.local:8443"], +] as const) { + macTest( + `authored native source carries public runtime host metadata (port ${httpsPort ?? "none"}, branch ${branch ?? "none"})`, + async () => { + const selected = await fixture({ branch }); + expect( + await serveNativeAuthoredProject({ + ...selected, + run, + httpsPort, + startupTimeoutMs: 10_000, + }) + ).toBe(0); + const recorded = (await Bun.file(selected.sources).text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(recorded.length).toBeGreaterThan(0); + for (const metadata of recorded) { + expect(Object.keys(metadata.runtime).sort()).toEqual(["off", "web"]); + const web = metadata.runtime.web; + expect(web.HACK_BRANCH).toBe(branch ?? ""); + expect(web.HACK_COMPOSE_PROJECT).toBe( + branch ? `fixture--${branch}` : "fixture" + ); + expect(web.HACK_DEV_URL).toBe(url); + expect(web.HACK_SERVICE_NAME).toBe("web"); + expect(metadata.runtime.off.HACK_SERVICE_NAME).toBe("off"); + expect(JSON.stringify(metadata)).not.toContain(CANARY); + } + } + ); +} diff --git a/tests/native-authored-runtime-metadata.test.ts b/tests/native-authored-runtime-metadata.test.ts new file mode 100644 index 000000000..e698c72a6 --- /dev/null +++ b/tests/native-authored-runtime-metadata.test.ts @@ -0,0 +1,415 @@ +import { expect, test } from "bun:test"; +import type { NativeProjectInput } from "../src/backends/native-project-input.ts"; +import { applyNativeRuntimeMetadata } from "../src/backends/native-runtime-metadata.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { + type NativeAuthoredRuntimeMetadataSelection, + nativeAuthoredRuntimeMetadata, +} from "../src/lib/native-authored-runtime-metadata.ts"; +import { + NativeComposeRenderError, + renderNativeCompose, +} from "../src/lib/native-compose-renderer.ts"; +import { + type NativeConfigPlanResult, + planNativeConfig, + resolveNativeConfig, +} from "../src/lib/native-config-compiler.ts"; +import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; +import { + RUNTIME_HOST_METADATA_KEY_NAMES, + type RuntimeHostMetadata, +} from "../src/lib/runtime-host-metadata.ts"; +import { requireTestConfigCompiler } from "./helpers/test-config-compiler.ts"; + +const binary = requireTestConfigCompiler(); +const encode = (value: unknown) => + new TextEncoder().encode(JSON.stringify(value)); + +/** Real compiler: resolve routing, derive the shared metadata, then plan with it. */ +async function authoredPlan(opts: { + readonly project: Record; + readonly branch?: string; + readonly selection: NativeAuthoredRuntimeMetadataSelection; + readonly workloads?: NativeEnvMetadata["workloads"]; +}): Promise> { + const input = encode(opts.project); + const resolved = await resolveNativeConfig({ + input, + binary, + branch: opts.branch, + requireRoutingPlanning: opts.branch !== undefined, + }); + if (!(resolved.ok && resolved.declared_workloads)) { + throw new Error("fixture project did not resolve"); + } + const runtime = nativeAuthoredRuntimeMetadata({ + projectName: String(resolved.plan.name), + declaredWorkloads: resolved.declared_workloads, + routing: resolved.routing_resolution, + selection: opts.selection, + }); + const planned = await planNativeConfig({ + input, + binary, + branch: opts.branch, + envMetadata: { + metadata_version: 1, + overlay: null, + overlay_exists: false, + workloads: + opts.workloads ?? + Object.fromEntries( + Object.keys(resolved.declared_workloads).map((name) => [name, {}]) + ), + inactive_scopes: [], + runtime, + }, + }); + if (!(planned.ok && planned.environment_plan.complete)) { + throw new Error("fixture project did not plan"); + } + return planned; +} + +function runtimeBindings( + planned: Extract, + workload: string +): Record { + return Object.fromEntries( + Object.entries(planned.environment_plan.workloads[workload] ?? {}).flatMap( + ([key, binding]) => + binding.kind === "runtime" ? [[key, binding.value] as const] : [] + ) + ); +} + +function imageInput(services: Record): NativeProjectInput { + return { + originalSha256: "d".repeat(64), + normalizedComposeJson: JSON.stringify({ services }), + managedEnvironment: {}, + lifecycleHostEnvironment: {}, + effectiveEnvName: null, + environmentFiles: [], + serviceNames: Object.keys(services), + }; +} + +function imageEnvironment( + input: NativeProjectInput, + service: string +): Record { + const compose: unknown = JSON.parse(input.normalizedComposeJson); + if (!(isRecord(compose) && isRecord(compose.services))) { + throw new Error("missing services"); + } + const selected = compose.services[service]; + if (!(isRecord(selected) && isRecord(selected.environment))) { + throw new Error(`missing ${service} environment`); + } + return selected.environment; +} + +test("authored native metadata matches the native image path for a branch instance on HTTPS port 8443", async () => { + // Native image project: dev host app.hack.local, branch feature, port 8443. + const image = applyNativeRuntimeMetadata({ + input: imageInput({ + web: { image: "example" }, + worker: { image: "example" }, + }), + selection: { + devHost: "app.hack.local", + aliasHost: null, + projectName: "app", + }, + branch: "feature", + httpsPort: 8443, + }).input; + // Authored project named app: the compiler generates feature.app.hack.local. + const planned = await authoredPlan({ + project: { + schema_version: 1, + name: "app", + services: { web: { image: "example:1" }, worker: { image: "example:1" } }, + }, + branch: "feature", + selection: { branch: "feature", httpsPort: 8443 }, + }); + for (const service of ["web", "worker"]) { + const authored = runtimeBindings(planned, service); + expect(authored).toEqual( + imageEnvironment(image, service) as Record + ); + expect(Object.keys(authored).sort()).toEqual( + RUNTIME_HOST_METADATA_KEY_NAMES.filter( + (key) => + key !== "HACK_ALIAS_HOST" && + key !== "HACK_ALIAS_URL" && + key !== "HACK_SERVICE_URL" + ).sort() + ); + } + const web = runtimeBindings(planned, "web"); + expect(web.HACK_BRANCH).toBe("feature"); + expect(web.HACK_COMPOSE_PROJECT).toBe("app--feature"); + expect(web.HACK_DEV_HOST).toBe("feature.app.hack.local"); + expect(web.HACK_DEV_URL).toBe("https://feature.app.hack.local:8443"); + expect(web.HACK_SERVICE_NAME).toBe("web"); + expect(web.HACK_SERVICE_URLS).toBe("[]"); + expect(JSON.parse(web.HACK_RUNTIME_METADATA ?? "")).toEqual({ + version: 1, + branch: "feature", + composeProject: "app--feature", + hosts: { dev: "feature.app.hack.local", alias: null }, + services: {}, + } satisfies RuntimeHostMetadata); +}); + +test("authored base instance without routing uses the compiler's generated origin and portless URLs", async () => { + const planned = await authoredPlan({ + project: { + schema_version: 1, + name: "app", + services: { web: { image: "example:1" } }, + jobs: { seed: { image: "example:1" } }, + }, + selection: { branch: null, httpsPort: 443 }, + }); + expect(planned.routing_resolution).toBeUndefined(); + const web = runtimeBindings(planned, "web"); + expect(web.HACK_BRANCH).toBe(""); + expect(web.HACK_COMPOSE_PROJECT).toBe("app"); + expect(web.HACK_DEV_URL).toBe("https://app.hack.local"); + expect(runtimeBindings(planned, "seed").HACK_SERVICE_NAME).toBe("seed"); +}); + +test("explicit authored and managed env win over generated metadata through the real compiler", async () => { + const planned = await authoredPlan({ + project: { + schema_version: 1, + name: "app", + services: { + web: { + image: "example:1", + environment: { + HACK_DEV_URL: { literal: "https://authored.example" }, + HACK_SERVICE_URLS: { unset: true }, + }, + }, + }, + }, + branch: "feature", + selection: { branch: "feature", httpsPort: 8443 }, + workloads: { + web: { HACK_BRANCH: { scope: "web", secret: false } }, + }, + }); + const bindings = planned.environment_plan.workloads.web ?? {}; + expect(bindings.HACK_DEV_URL).toEqual({ + kind: "literal", + value: "https://authored.example", + }); + expect(bindings.HACK_BRANCH).toEqual({ + kind: "managed", + key: "HACK_BRANCH", + scope: "web", + secret: false, + }); + expect(bindings.HACK_SERVICE_URLS).toBeUndefined(); + expect(bindings.HACK_DEV_HOST).toEqual({ + kind: "runtime", + value: "feature.app.hack.local", + }); +}); + +const ROUTED = { + schema_version: 1, + name: "app", + routes: { + aliases: { oauth: { domain: "hack.gy" } }, + oauth_alias: "oauth", + http: { + web: { service: "web", port: 3000, hostname: "project" }, + api: { service: "api", port: 4000, hostname: "api" }, + }, + }, + services: { + web: { + image: "example:1", + environment: { HACK_DEV_URL: { literal: "https://authored.example" } }, + }, + api: { image: "example:1" }, + worker: { image: "example:1" }, + }, +}; + +async function routedRender(withRuntime: boolean) { + const composeProject = `hack-nc-${"a".repeat(32)}`; + const planned = await authoredPlan({ + project: ROUTED, + branch: "feature", + selection: { branch: "feature", composeProject, httpsPort: null }, + }); + const workloads = withRuntime + ? planned.environment_plan.workloads + : Object.fromEntries( + Object.entries(planned.environment_plan.workloads).map( + ([name, bindings]) => [ + name, + Object.fromEntries( + Object.entries(bindings).filter( + ([, binding]) => binding.kind !== "runtime" + ) + ), + ] + ) + ); + return renderNativeCompose({ + plan: planned.plan, + environmentPlan: { ...planned.environment_plan, workloads }, + projectRoot: "/verified/checkout", + runtimeIdentity: composeProject, + ownerToken: "c".repeat(32), + generationIdentity: "b".repeat(32), + managedValues: { web: {}, api: {}, worker: {} }, + routingResolution: planned.routing_resolution, + declaredWorkloads: planned.declared_workloads, + }); +} + +test("authored Compose render gains route-derived metadata while every other byte stays the same", async () => { + const rendered = await routedRender(true); + const baseline = await routedRender(false); + const services = rendered.document.services; + const env = (name: string) => { + const service = services[name]; + if (!(service && isRecord(service.environment))) { + throw new Error(`missing ${name} environment`); + } + return service.environment as Record; + }; + expect(env("web").HACK_DEV_URL).toBe("https://authored.example"); + expect(env("web").HACK_SERVICE_URL).toBe("https://feature.app.hack.local"); + expect(JSON.parse(env("web").HACK_SERVICE_URLS ?? "")).toEqual([ + "https://feature.app.hack.local", + "https://feature.app.hack.gy", + ]); + expect(env("api").HACK_DEV_URL).toBe("https://feature.app.hack.local"); + expect(env("api").HACK_SERVICE_URL).toBe( + "https://api.feature.app.hack.local" + ); + expect(env("api").HACK_ALIAS_HOST).toBe("feature.app.hack.gy"); + expect(env("api").HACK_ALIAS_URL).toBe("https://feature.app.hack.gy"); + expect(env("api").HACK_COMPOSE_PROJECT).toBe(`hack-nc-${"a".repeat(32)}`); + expect(env("worker").HACK_SERVICE_URL).toBeUndefined(); + expect(env("worker").HACK_SERVICE_URLS).toBe("[]"); + const document = JSON.parse( + env("worker").HACK_RUNTIME_METADATA ?? "" + ) as RuntimeHostMetadata; + expect(document).toEqual({ + version: 1, + branch: "feature", + composeProject: `hack-nc-${"a".repeat(32)}`, + hosts: { dev: "feature.app.hack.local", alias: "feature.app.hack.gy" }, + services: { + api: { + urls: [ + "https://api.feature.app.hack.local", + "https://api.feature.app.hack.gy", + ], + }, + web: { + urls: ["https://feature.app.hack.local", "https://feature.app.hack.gy"], + }, + }, + }); + // Removing exactly the generated keys yields the metadata-free render, byte for byte. + const stripped = structuredClone(rendered.document) as Record< + string, + unknown + >; + for (const service of Object.values( + stripped.services as Record> + )) { + const environment = service.environment as Record; + for (const key of RUNTIME_HOST_METADATA_KEY_NAMES) { + if ( + key !== "HACK_DEV_URL" || + environment[key] !== "https://authored.example" + ) { + delete environment[key]; + } + } + } + expect(`${JSON.stringify(stripped, null, 2)}\n`).toBe(baseline.json); + expect(rendered.json).not.toBe(baseline.json); +}); + +test("the renderer refuses generated bindings that shadow owned keys or leave the reserved set", async () => { + const planned = await authoredPlan({ + project: ROUTED, + branch: "feature", + selection: { + branch: "feature", + composeProject: `hack-nc-${"a".repeat(32)}`, + httpsPort: null, + }, + }); + const render = ( + web: Record, + managedValues: Record> = { + web: {}, + api: {}, + worker: {}, + } + ) => + renderNativeCompose({ + plan: planned.plan, + environmentPlan: { + ...planned.environment_plan, + workloads: { ...planned.environment_plan.workloads, web }, + }, + projectRoot: "/verified/checkout", + runtimeIdentity: `hack-nc-${"a".repeat(32)}`, + ownerToken: "c".repeat(32), + generationIdentity: "b".repeat(32), + managedValues, + routingResolution: planned.routing_resolution, + declaredWorkloads: planned.declared_workloads, + }); + const web = planned.environment_plan.workloads.web ?? {}; + const expectRefusal = (action: () => unknown, code: string) => { + try { + action(); + } catch (error: unknown) { + expect(error).toBeInstanceOf(NativeComposeRenderError); + expect((error as NativeComposeRenderError).code).toBe(code); + return; + } + throw new Error("expected a renderer refusal"); + }; + // Positive control: the compiler output itself renders. + expect(() => render(web)).not.toThrow(); + // A generated binding for a non-reserved key fails the closed plan parse. + expectRefusal( + () => render({ ...web, OTHER: { kind: "runtime", value: "x" } }), + "E_COMPOSE_ENV_INCOMPLETE" + ); + // A generated binding where the authored directive owns the key. + expectRefusal( + () => + render({ ...web, HACK_DEV_URL: { kind: "runtime", value: "forged" } }), + "E_COMPOSE_ENV_MISMATCH" + ); + // A generated binding where a managed value owns the key. + expectRefusal( + () => + render(web, { + web: { HACK_BRANCH: "managed" }, + api: {}, + worker: {}, + }), + "E_COMPOSE_ENV_MISMATCH" + ); +}); From 20679e46fbb02e3b9ab5ce5507cc44c19e991f1a Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 13 Oct 2026 00:08:50 -0400 Subject: [PATCH 7/9] test(native): cover authored Compose runtime metadata delivery and wire guards Add a prepareCommand-level regression test: authored Compose `up` through the source CLI renders the reserved runtime metadata keys, and HACK_COMPOSE_PROJECT equals the generation store's hack-nc-... project for the default and a --branch instance. A checkout replaced between metadata selection and store open refuses before any Compose effect. Cover the TS wire guards: runtime metadata with non-reserved keys, undeclared workloads or non-text values refuses before the compiler spawns, and host reports carrying runtime bindings are rejected. Co-Authored-By: Claude Opus 5.5 --- tests/helpers/native-compose-command.ts | 14 +- ...e-compose-runtime-metadata-command.test.ts | 155 ++++++++++++++++++ tests/native-env-plan-transport.test.ts | 94 ++++++++++- 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 tests/native-compose-runtime-metadata-command.test.ts diff --git a/tests/helpers/native-compose-command.ts b/tests/helpers/native-compose-command.ts index 2aebc693f..5fb8e696c 100644 --- a/tests/helpers/native-compose-command.ts +++ b/tests/helpers/native-compose-command.ts @@ -38,6 +38,15 @@ export async function fixture( readonly hooks?: unknown; readonly noHooks?: boolean; readonly oneoff?: boolean; + /** + * Echo request `env_metadata.runtime` as public `runtime` bindings below + * managed values, as the real compiler does, and record each plan request's + * runtime selection in `/compiler-runtime`. Off keeps the stand-in + * ignoring runtime metadata. + */ + readonly runtimeMetadata?: boolean; + /** Stand-in source run on each `plan` request, before it responds. */ + readonly onPlan?: string; readonly cleanupAllowed?: () => boolean; readonly storage?: Readonly< Record< @@ -143,7 +152,10 @@ else { if(operation!=="compile") result.local_resolution={overlay:null,origin:"project",auto_branch:false,inherit_local:false,resolution_hash:"b".repeat(64)}; if(operation==="plan") { const metadata=request.env_metadata;const bindings=entries=>Object.fromEntries(Object.entries(entries).map(([key,entry])=>[key,{kind:"managed",key,scope:entry.scope,secret:entry.secret}])); - result.environment_plan={plan_version:1,overlay:metadata.overlay,overlay_exists:metadata.overlay_exists,complete:true,workloads:{web:bindings(metadata.workloads.web)},host:Object.fromEntries(allHooks.map(h=>[h.name,{env_target:h.env_target,bindings:bindings(metadata.host.default)}])),warnings:[],diagnostics:[]}; + ${opts.onPlan ?? ""} + ${opts.runtimeMetadata ? 'await import("node:fs/promises").then(m=>m.appendFile("compiler-runtime",JSON.stringify(metadata.runtime??null)+"\\n"));' : ""} + const runtime=${opts.runtimeMetadata ? 'Object.fromEntries(Object.entries(metadata.runtime?.web??{}).map(([key,value])=>[key,{kind:"runtime",value}]))' : "{}"}; + result.environment_plan={plan_version:1,overlay:metadata.overlay,overlay_exists:metadata.overlay_exists,complete:true,workloads:{web:{...runtime,...bindings(metadata.workloads.web)}},host:Object.fromEntries(allHooks.map(h=>[h.name,{env_target:h.env_target,bindings:bindings(metadata.host.default)}])),warnings:[],diagnostics:[]}; } if(result.environment_plan && allHooks.length===0) delete result.environment_plan.host; console.log(JSON.stringify(result)); diff --git a/tests/native-compose-runtime-metadata-command.test.ts b/tests/native-compose-runtime-metadata-command.test.ts new file mode 100644 index 000000000..c6b8cd287 --- /dev/null +++ b/tests/native-compose-runtime-metadata-command.test.ts @@ -0,0 +1,155 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { lstat, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { RUNTIME_HOST_METADATA_KEY_NAMES } from "../src/lib/runtime-host-metadata.ts"; +import { fixture, invoke } from "./helpers/native-compose-command.ts"; + +/** + * Authored Compose `up` through the real source CLI and `prepareCommand`. The + * compiler and Docker are stand-ins: the compiler echoes the requested runtime + * metadata as `runtime` bindings, as the real compiler does, and Docker records + * the rendered Compose document it was asked to start. + */ + +const moved: string[] = []; +afterEach(async () => { + await Promise.all( + moved.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ); +}); + +type Engine = { + readonly name: string; + readonly services: { + readonly web: { readonly environment: Record }; + }; +}; + +async function engine(root: string): Promise { + return await Bun.file(join(root, "engine")).json(); +} + +async function composeStarts(root: string): Promise { + const path = join(root, "requests"); + if (!(await Bun.file(path).exists())) { + return []; + } + return (await Bun.file(path).text()) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as string[]) + .filter((args) => args[0] === "compose" && args.includes("up")); +} + +/** Independent recomputation of the generation store's Compose project identity. */ +async function storeComposeProject( + root: string, + instance: string | null +): Promise { + const info = await lstat(root); + const id = createHash("sha256") + .update(JSON.stringify([root, info.dev, info.ino, instance])) + .digest("hex"); + return `hack-nc-${id.slice(0, 32)}`; +} + +function generated(environment: Record) { + return Object.fromEntries( + Object.entries(environment).filter(([key]) => + RUNTIME_HOST_METADATA_KEY_NAMES.includes(key) + ) + ); +} + +test.each([ + { name: "default instance", instance: null }, + { name: "branch instance", instance: "feature" }, +] as const)("authored Compose up renders runtime metadata with the store's Compose project: $name", async ({ + instance, +}) => { + const root = await fixture("", false, { + noHooks: true, + runtimeMetadata: true, + }); + const args = [ + "up", + ...(instance === null ? [] : ["--branch", instance]), + "--detach", + "--json", + ]; + const result = await invoke(root, args); + expect(result.code).toBe(0); + + const composeProject = await storeComposeProject(root, instance); + const document = await engine(root); + // The rendered document is named by the opened store's identity. + expect(document.name).toBe(composeProject); + const starts = await composeStarts(root); + expect(starts).toHaveLength(1); + expect(starts[0]?.[starts[0].indexOf("-p") + 1]).toBe(composeProject); + + const environment = document.services.web.environment; + const devHost = + instance === null ? "fixture.hack.local" : `${instance}.fixture.hack.local`; + const expected = { + HACK_BRANCH: instance ?? "", + HACK_COMPOSE_PROJECT: composeProject, + HACK_DEV_HOST: devHost, + HACK_DEV_URL: `https://${devHost}`, + HACK_SERVICE_NAME: "web", + HACK_SERVICE_URLS: "[]", + HACK_RUNTIME_METADATA: JSON.stringify({ + version: 1, + branch: instance, + composeProject, + hosts: { dev: devHost, alias: null }, + services: {}, + }), + }; + expect(generated(environment)).toEqual(expected); + // Managed env still delivers beside the generated metadata. + expect(environment.TOKEN).toBe("global"); + + // The compiler planned exactly this selection, once per planning request. + const requested = (await Bun.file(join(root, "compiler-runtime")).text()) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); + expect(requested.length).toBeGreaterThan(0); + for (const runtime of requested) { + expect(runtime).toEqual({ web: expected }); + } +}, 30_000); + +test("authored Compose up refuses before any engine effect when the checkout identity changes after metadata selection", async () => { + const root = await fixture("", false, { + noHooks: true, + runtimeMetadata: true, + // Once, while the first plan request is in flight (after prepareCommand + // selected the runtime metadata and before it opens the generation store), + // replace the checkout with a copy: same path, new inode, so the store + // derives a different Compose project than the metadata reports. + onPlan: `{const fs=await import("node:fs/promises");const checkout=process.cwd(); + if(!(await Bun.file(checkout+".swapped").exists())){await fs.writeFile(checkout+".swapped","");await fs.rename(checkout,checkout+".moved");await fs.cp(checkout+".moved",checkout,{recursive:true});}}`, + }); + moved.push(`${root}.moved`, `${root}.swapped`); + const selected = await storeComposeProject(root, null); + + const result = await invoke(root); + + expect(await Bun.file(`${root}.swapped`).exists()).toBe(true); + expect(await storeComposeProject(root, null)).not.toBe(selected); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { + code: "E_CONFIG_INVALID", + message: "The saved native Compose selection is invalid; values omitted.", + }, + }); + expect(result.stdout).not.toContain(selected); + expect(await composeStarts(root)).toEqual([]); + expect(await composeStarts(`${root}.moved`)).toEqual([]); + expect(await Bun.file(join(root, "engine")).exists()).toBe(false); +}, 30_000); diff --git a/tests/native-env-plan-transport.test.ts b/tests/native-env-plan-transport.test.ts index 1bb0f6b52..c9cc8e477 100644 --- a/tests/native-env-plan-transport.test.ts +++ b/tests/native-env-plan-transport.test.ts @@ -9,7 +9,10 @@ import { planNativeConfig, resolveNativeConfig, } from "../src/lib/native-config-compiler.ts"; -import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; +import { + type NativeEnvMetadata, + parseNativeEnvironmentPlan, +} from "../src/lib/native-env-plan-protocol.ts"; import { restoreEnv } from "./helpers/env.ts"; const PROTOCOL = { @@ -301,6 +304,95 @@ test("rejects oversized metadata before spawning", async () => { expect(await Bun.file(receipt).exists()).toBe(false); }); +test("sends reserved runtime metadata for declared workloads and refuses any other runtime entry before spawning", async () => { + const receipt = join(directory, "spawn-receipt"); + const request = join(directory, "request"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + body: `await Bun.write(${JSON.stringify(request)}, await Bun.stdin.text()); process.stdout.write(${JSON.stringify(JSON.stringify(SUCCESS))});`, + }); + const refused = [ + { name: "non-reserved key", runtime: { web: { APP_MODE: CANARY } } }, + { name: "lowercase key", runtime: { web: { hack_branch: CANARY } } }, + { + name: "undeclared workload", + runtime: { other: { HACK_BRANCH: CANARY } }, + }, + { name: "NUL value", runtime: { web: { HACK_BRANCH: `${CANARY}\0` } } }, + { name: "non-string value", runtime: { web: { HACK_BRANCH: 1 } } }, + { name: "array workload", runtime: { web: [CANARY] } }, + { name: "array runtime", runtime: [CANARY] }, + ]; + for (const { runtime } of refused) { + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + envMetadata: { ...METADATA, runtime } as unknown as NativeEnvMetadata, + binary, + }), + code: "E_CONFIG_METADATA", + message: + "Native environment metadata is invalid or exceeds its budget; values omitted.", + }); + expect(await Bun.file(receipt).exists()).toBe(false); + } + // Positive control: the same wire with reserved keys reaches the compiler. + const runtime = { + web: { HACK_BRANCH: "feature", HACK_SERVICE_NAME: "web" }, + }; + expect( + await planNativeConfig({ + input: INPUT, + envMetadata: { ...METADATA, runtime }, + binary, + }) + ).toEqual(SUCCESS); + expect(await Bun.file(receipt).exists()).toBe(true); + expect( + JSON.parse(await Bun.file(request).text()).env_metadata.runtime + ).toEqual(runtime); +}); + +test("accepts runtime bindings for reserved workload keys and refuses them in host reports", () => { + const parse = (value: unknown) => + parseNativeEnvironmentPlan({ + value, + parseDiagnostic: () => { + throw new Error("No diagnostics are expected"); + }, + }); + const host = (binding: unknown) => ({ + ...ENVIRONMENT_PLAN, + host: { + before: { + env_target: { kind: "host" }, + bindings: { HACK_BRANCH: binding }, + }, + }, + }); + const workload = (key: string) => ({ + ...ENVIRONMENT_PLAN, + workloads: { + web: { + ...ENVIRONMENT_PLAN.workloads.web, + [key]: { kind: "runtime", value: "feature" }, + }, + }, + }); + // Positive controls: the same host report shape with an authored literal, + // and a reserved runtime binding on a workload. + expect( + parse(host({ kind: "literal", value: "feature" }))?.host?.before?.bindings + ).toEqual({ HACK_BRANCH: { kind: "literal", value: "feature" } }); + expect(parse(workload("HACK_BRANCH"))?.workloads.web?.HACK_BRANCH).toEqual({ + kind: "runtime", + value: "feature", + }); + // Generated container metadata never reaches host processes. + expect(parse(host({ kind: "runtime", value: "feature" }))).toBeNull(); + expect(parse(workload("APP_MODE"))).toBeNull(); +}); + test("bounds original documents and UTF-8 before spawning", async () => { const receipt = join(directory, "spawn-receipt"); const binary = await fixture({ From 34feed058a356df22f16528aaf24314cc8bb94bf Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 13 Oct 2026 00:29:44 -0400 Subject: [PATCH 8/9] test(native): cover image up/restart env fingerprint wiring and tighten exec output assertions - Add a command-level test for handleNativeUp with scoped module mocks for start, restart and down: up records the reported env under the image owner and effective branch and releases it (also on failure); restart drops the previous run's record at down (also when down fails), records the new run and releases it; a restart refused before down keeps the running record. - Ctrl-C advisory test: capture output in an ordered log instead of asserting on a restored spy, whose recorded calls mockRestore() clears. - Authored exec ordering test: capture stderr and Bun.write in one ordered log with non-empty command stdout/stderr, so a warning moved after the output fails. Co-Authored-By: Claude Opus 5.5 --- tests/native-exec-stale-env.test.ts | 69 ++-- .../native-up-env-fingerprint-wiring.test.ts | 363 ++++++++++++++++++ 2 files changed, 410 insertions(+), 22 deletions(-) create mode 100644 tests/native-up-env-fingerprint-wiring.test.ts diff --git a/tests/native-exec-stale-env.test.ts b/tests/native-exec-stale-env.test.ts index 8e1eb7d71..9c3d901f3 100644 --- a/tests/native-exec-stale-env.test.ts +++ b/tests/native-exec-stale-env.test.ts @@ -675,16 +675,45 @@ describe("authored native command wiring", () => { }; } + /** + * Capture process.stderr writes and Bun.write output in one ordered log. + * Bun.write entries name their destination so stdout and stderr stay + * distinguishable. + */ + function orderedOutput() { + const events: string[] = []; + const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { + events.push(`process.stderr:${String(chunk)}`); + return true; + }); + const writes = spyOn(Bun, "write").mockImplementation( + async (destination, data) => { + const target = + destination === Bun.stdout + ? "Bun.stdout" + : destination === Bun.stderr + ? "Bun.stderr" + : "other"; + events.push( + `${target}:${new TextDecoder().decode(data as Uint8Array)}` + ); + return 0; + } + ); + return { + events, + restore: () => { + err.mockRestore(); + writes.mockRestore(); + }, + }; + } + macTest( "exec writes the warning to stderr before the command's output", async () => { const selected = await fixture(); - const stderr: string[] = []; - const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { - stderr.push(String(chunk)); - return true; - }); - const writes = spyOn(Bun, "write").mockImplementation(async () => 0); + const io = orderedOutput(); let code: number | null; try { code = await tryNativeAuthoredCommand({ @@ -702,8 +731,8 @@ describe("authored native command wiring", () => { service: "web", container: "f".repeat(64), exitCode: 0, - stdout: new Uint8Array(), - stderr: new Uint8Array(), + stdout: new TextEncoder().encode("command-out\n"), + stderr: new TextEncoder().encode("command-err\n"), truncated: false, }), staleEnv: async (request) => @@ -713,12 +742,13 @@ describe("authored native command wiring", () => { }), }); } finally { - err.mockRestore(); - writes.mockRestore(); + io.restore(); } expect(code).toBe(0); - expect(stderr).toEqual([ - `WARNING: ${buildStaleNativeEnvWarning({ service: "web", exec: "started" })}\n`, + expect(io.events).toEqual([ + `process.stderr:WARNING: ${buildStaleNativeEnvWarning({ service: "web", exec: "started" })}\n`, + "Bun.stdout:command-out\n", + "Bun.stderr:command-err\n", ]); } ); @@ -740,12 +770,9 @@ describe("authored native command wiring", () => { "Ctrl-C during the advisory check returns 130 without waiting for it or writing output", async () => { const selected = await fixture(); - const stderr: string[] = []; - const err = spyOn(process.stderr, "write").mockImplementation((chunk) => { - stderr.push(String(chunk)); - return true; - }); - const writes = spyOn(Bun, "write").mockImplementation(async () => 0); + // mockRestore() clears a spy's recorded calls, so the output is + // captured into this log rather than read back from the spies. + const io = orderedOutput(); const received: { signal?: AbortSignal } = {}; let code: number | null; try { @@ -767,13 +794,11 @@ describe("authored native command wiring", () => { }, }); } finally { - err.mockRestore(); - writes.mockRestore(); + io.restore(); } expect(code).toBe(130); expect(received.signal?.aborted).toBe(true); - expect(writes).not.toHaveBeenCalled(); - expect(stderr).toEqual([]); + expect(io.events).toEqual([]); }, 2000 ); diff --git a/tests/native-up-env-fingerprint-wiring.test.ts b/tests/native-up-env-fingerprint-wiring.test.ts new file mode 100644 index 000000000..2a767e972 --- /dev/null +++ b/tests/native-up-env-fingerprint-wiring.test.ts @@ -0,0 +1,363 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { + mkdir, + mkdtemp, + readdir, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, +} from "../src/constants.ts"; +import type { ComposeEnvFingerprintRecord } from "../src/lib/compose-env-fingerprint.ts"; +import { + createNativeEnvFingerprintEntry, + loadNativeEnvFingerprint, + type NativeEnvFingerprintEntry, + type NativeEnvFingerprintScope, + saveNativeEnvFingerprint, +} from "../src/lib/native-env-fingerprint-state.ts"; +import { restoreEnv } from "./helpers/env.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +// HACK-1140 command wiring: `hack up` / `hack restart` on the native image +// backend record the env-selection fingerprint the run reports, bound to the +// image owner and the effective branch; release it when the foreground ends; +// and a restart drops the previous run's record once that run is stopping. +// The runtime boundaries (start, restart orchestration, down) are mocked; the +// fingerprint store under test is the real one on a temporary project. + +const RUN_OLD = "0".repeat(32); +const RUN_NEW = "1".repeat(32); +const BRANCH = "feature-a"; + +type StartOptions = { + readonly onEnvironmentReady?: (selection: { + readonly run: string; + readonly fingerprint: ComposeEnvFingerprintRecord; + }) => Promise; +}; +type RestartOptions = { + readonly down: () => Promise; + readonly start: (options: { + run: unknown; + onReady: () => Promise; + }) => Promise; +}; + +let startBehavior: (opts: StartOptions) => Promise = async () => 0; +let restartBehavior: (opts: RestartOptions) => Promise = async () => 0; +let downBehavior: () => Promise<{ status: string }> = async () => ({ + status: "stopped", +}); +const calls: string[] = []; + +const startMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-start.ts", + overrides: { + startNativeProject: async (opts: StartOptions) => { + calls.push("start"); + return await startBehavior(opts); + }, + }, +}); +const restartMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-restart.ts", + overrides: { + restartNativeProject: async (opts: RestartOptions) => { + calls.push("restart"); + return await restartBehavior(opts); + }, + }, +}); +const downMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/backends/native-project-down.ts", + overrides: { + nativeProjectDown: async () => { + calls.push("down"); + return await downBehavior(); + }, + }, +}); +// Keep this a legacy Compose project test: no authored or adopted dispatch. +const authoredMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/native-compose-command.ts", + overrides: { tryNativeComposeCommand: async () => null }, +}); +const branchesMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/branches.ts", + overrides: { + resolveEffectiveBranch: async () => ({ + branch: null, + source: "none", + gitBranch: null, + }), + }, +}); +const { restartCommand, upCommand } = await import( + "../src/commands/project.ts" +); + +const ENV_KEYS = [ + "HACK_HOME", + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_NATIVE_HTTPS_PORT", + "HACK_NATIVE_CADDY_BINARY", + "HACK_NATIVE_CADDY_SHA256", + "HACK_NATIVE_SHARED_SOURCE", +] as const; +const savedEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]])); +const roots: string[] = []; +const mocks = [startMock, restartMock, downMock, authoredMock, branchesMock]; + +beforeAll(() => { + for (const mocked of mocks) { + mocked.activate(); + } +}); +afterEach(async () => { + startBehavior = async () => 0; + restartBehavior = async () => 0; + downBehavior = async () => ({ status: "stopped" }); + calls.length = 0; + for (const key of ENV_KEYS) { + restoreEnv(key, savedEnv.get(key)); + } + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ); +}); +afterAll(() => { + for (const mocked of mocks) { + mocked.deactivate(); + } +}); + +function fingerprint(seed: string): ComposeEnvFingerprintRecord { + return { + version: 1, + salt: seed.repeat(32), + services: { web: seed.repeat(64) }, + }; +} + +async function nativeProject(): Promise<{ + readonly root: string; + readonly scope: NativeEnvFingerprintScope; +}> { + const root = await realpath( + await mkdtemp(join(tmpdir(), "native-up-env-fingerprint-")) + ); + roots.push(root); + await mkdir(join(root, ".hack")); + await writeFile( + join(root, ".hack", PROJECT_COMPOSE_FILENAME), + "name: fingerprint-compose\nservices:\n web:\n image: alpine:3.20\n" + ); + await writeFile( + join(root, ".hack", PROJECT_CONFIG_FILENAME), + JSON.stringify({ dev_host: "fingerprint.hack" }) + ); + for (const key of ENV_KEYS) { + Reflect.deleteProperty(process.env, key); + } + process.env.HACK_HOME = join(root, ".global-hack"); + process.env.HACK_RUNTIME_BACKEND = "native"; + process.env.HACK_NATIVE_BINARY = join(root, "hack-local"); + process.env.HACK_NATIVE_HOME = join(root, "native-home"); + process.env.HACK_NATIVE_SHARED_SOURCE = "1"; + return { + root, + scope: { projectDir: join(root, ".hack"), owner: "image", branch: BRANCH }, + }; +} + +async function up(root: string): Promise { + const input = { + ctx: { cwd: root, cli: CLI_SPEC }, + args: { + options: { + path: root, + project: undefined, + env: undefined, + branch: BRANCH, + detach: false, + profile: undefined, + target: undefined, + json: false, + }, + positionals: { services: [] }, + raw: { argv: ["--path", root, "--branch", BRANCH], positionals: [] }, + }, + } as unknown as Parameters[0]; + return await upCommand.handler(input); +} + +async function restart(root: string): Promise { + const input = { + ctx: { cwd: root, cli: CLI_SPEC }, + args: { + options: { + path: root, + project: undefined, + env: undefined, + branch: BRANCH, + profile: undefined, + target: undefined, + json: false, + recoverFrontend: false, + expectFinalizationAttempt: undefined, + expectFrontendPid: undefined, + }, + positionals: { services: [] }, + raw: { argv: ["--path", root, "--branch", BRANCH], positionals: [] }, + }, + } as unknown as Parameters[0]; + return await restartCommand.handler(input); +} + +/** Start behavior that reports `run`'s env and snapshots the record while running. */ +function reportingStart( + scope: NativeEnvFingerprintScope, + run: string, + seen: { entry?: NativeEnvFingerprintEntry | null }, + outcome: () => Promise = async () => 0 +) { + return async (opts: StartOptions) => { + await opts.onEnvironmentReady?.({ run, fingerprint: fingerprint("e") }); + seen.entry = await loadNativeEnvFingerprint(scope); + return await outcome(); + }; +} + +async function seedPreviousRun(scope: NativeEnvFingerprintScope) { + await saveNativeEnvFingerprint( + scope, + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN_OLD, + fingerprint: fingerprint("d"), + }) + ); +} + +const restartedRun = { + run: RUN_NEW, + effectiveEnvName: null, + profiles: [], + aws: null, +}; + +test("native up records the reported env under the image owner and branch, then releases it", async () => { + const { root, scope } = await nativeProject(); + const seen: { entry?: NativeEnvFingerprintEntry | null } = {}; + startBehavior = reportingStart(scope, RUN_NEW, seen); + expect(await up(root)).toBe(0); + expect(seen.entry).toEqual( + createNativeEnvFingerprintEntry({ + owner: "image", + run: RUN_NEW, + fingerprint: fingerprint("e"), + }) + ); + // Released when the foreground ended; nothing else is left behind. + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); + expect( + await readdir( + join(scope.projectDir, ".internal", "native-env-fingerprints") + ) + ).toEqual([]); +}); + +test("native up releases its record when startup fails after reporting", async () => { + const { root, scope } = await nativeProject(); + const seen: { entry?: NativeEnvFingerprintEntry | null } = {}; + startBehavior = reportingStart(scope, RUN_NEW, seen, () => + Promise.reject(new Error("synthetic startup failure")) + ); + await expect(up(root)).rejects.toThrow("synthetic startup failure"); + expect(seen.entry?.run).toBe(RUN_NEW); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("native up release keeps a record another owner wrote meanwhile", async () => { + const { root, scope } = await nativeProject(); + startBehavior = async (opts) => { + await opts.onEnvironmentReady?.({ + run: RUN_NEW, + fingerprint: fingerprint("e"), + }); + // A newer foreground replaced the record before this one ended. + await seedPreviousRun(scope); + return 0; + }; + expect(await up(root)).toBe(0); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(RUN_OLD); +}); + +test("native restart drops the previous run's record at down, records the new run, then releases", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + const seen: { entry?: NativeEnvFingerprintEntry | null } = {}; + const beforeDown: { entry?: NativeEnvFingerprintEntry | null } = {}; + startBehavior = reportingStart(scope, RUN_NEW, seen); + restartBehavior = async (opts) => { + beforeDown.entry = await loadNativeEnvFingerprint(scope); + await opts.down(); + return await opts.start({ run: restartedRun, onReady: async () => {} }); + }; + expect(await restart(root)).toBe(0); + expect(calls).toEqual(["restart", "down", "start"]); + expect(beforeDown.entry?.run).toBe(RUN_OLD); + expect(seen.entry?.run).toBe(RUN_NEW); + expect(seen.entry?.fingerprint).toEqual(fingerprint("e")); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("a native restart that fails after down leaves no record", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + restartBehavior = async (opts) => { + await opts.down(); + throw new Error("synthetic restart start failure"); + }; + await expect(restart(root)).rejects.toThrow( + "synthetic restart start failure" + ); + expect(calls).toEqual(["restart", "down"]); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("a native restart whose down fails still drops the previous run's record", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + downBehavior = () => Promise.reject(new Error("synthetic down failure")); + restartBehavior = async (opts) => { + await opts.down(); + return 0; + }; + await expect(restart(root)).rejects.toThrow("synthetic down failure"); + expect(await loadNativeEnvFingerprint(scope)).toBeNull(); +}); + +test("negative control: a native restart refused before down keeps the running record", async () => { + const { root, scope } = await nativeProject(); + await seedPreviousRun(scope); + restartBehavior = () => + Promise.reject(new Error("synthetic preflight refusal")); + await expect(restart(root)).rejects.toThrow("synthetic preflight refusal"); + expect(calls).toEqual(["restart"]); + expect((await loadNativeEnvFingerprint(scope))?.run).toBe(RUN_OLD); +}); From 7745fc27e8e4de6360b2db5da180fe2a958d2f97 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Tue, 13 Oct 2026 00:35:57 -0400 Subject: [PATCH 9/9] test(agents): pin onboarding entry-point gaps and pass repoRoot from no-shell clients - Pin the value-free hack-init MCP catch: an injected builder failure whose message carries a path and a credential value must surface only the closed `: hack-init could not inventory the repo.` text. - Pin the `` placeholder for an existing setup with no dev_host. - Give interactive `hack init` a terminal seam (TTY check, prompts, service wizard, agent-step prompts) and test that its `--with` handoff frames a scaffolded run as `created` and a kept `.hack/` as `existing`. - The /hack-init skill and canonical agent-setup guidance tell no-shell clients to pass `repoRoot` to the hack-init MCP prompt so it includes the repo inventory; renders, plugins, fingerprints and revision regenerated. - Trim repoRoot once so the presence check and the resolved path agree. HACK-1137 Co-Authored-By: Claude Opus 5.5 --- .claude/skills/hack-init/SKILL.md | 2 +- .codex/skills/hack-cli/SKILL.md | 4 +- .codex/skills/hack-init/SKILL.md | 2 +- .cursor/rules/hack.mdc | 2 +- docs/guides/agent-first-setup.md | 3 +- examples/basic/AGENTS.md | 4 +- examples/basic/CLAUDE.md | 4 +- plugins/hack/rules/hack.mdc | 2 +- plugins/hack/skills/hack-branches/SKILL.md | 2 +- plugins/hack/skills/hack-cli/SKILL.md | 4 +- plugins/hack/skills/hack-diagnose/SKILL.md | 2 +- plugins/hack/skills/hack-env/SKILL.md | 2 +- plugins/hack/skills/hack-init/SKILL.md | 2 +- src/agents/hack-init-skill.ts | 2 +- src/agents/instruction-source.ts | 2 +- src/agents/integration-ownership.ts | 3 + src/agents/integration-revision.ts | 2 +- src/commands/project.ts | 110 +++++++++++---- src/mcp/server.ts | 22 ++- tests/hack-init-skill.test.ts | 4 + tests/helpers/interactive-init-runner.ts | 62 +++++++++ tests/onboarding-entry-parity.test.ts | 154 ++++++++++++++++++++- 22 files changed, 340 insertions(+), 56 deletions(-) create mode 100644 tests/helpers/interactive-init-runner.ts diff --git a/.claude/skills/hack-init/SKILL.md b/.claude/skills/hack-init/SKILL.md index 1b186c461..74638c097 100644 --- a/.claude/skills/hack-init/SKILL.md +++ b/.claude/skills/hack-init/SKILL.md @@ -12,6 +12,6 @@ Fetch the current onboarding guidance from the installed Hack CLI. 1. Fetch the canonical onboarding prompt: - With shell access: run `hack agent onboard` (works for new and existing projects). - - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. + - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. 2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership. 3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion. diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index 7d29dac14..e62f0006b 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -14,7 +14,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). ## Product boundary @@ -192,6 +192,6 @@ Use `hack` as the primary interface for local-first development. - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/.codex/skills/hack-init/SKILL.md b/.codex/skills/hack-init/SKILL.md index 1b186c461..74638c097 100644 --- a/.codex/skills/hack-init/SKILL.md +++ b/.codex/skills/hack-init/SKILL.md @@ -12,6 +12,6 @@ Fetch the current onboarding guidance from the installed Hack CLI. 1. Fetch the canonical onboarding prompt: - With shell access: run `hack agent onboard` (works for new and existing projects). - - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. + - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. 2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership. 3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion. diff --git a/.cursor/rules/hack.mdc b/.cursor/rules/hack.mdc index d80ad06fa..ef9e183de 100644 --- a/.cursor/rules/hack.mdc +++ b/.cursor/rules/hack.mdc @@ -9,7 +9,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). ## Product boundary diff --git a/docs/guides/agent-first-setup.md b/docs/guides/agent-first-setup.md index 54602c57a..321cf259b 100644 --- a/docs/guides/agent-first-setup.md +++ b/docs/guides/agent-first-setup.md @@ -62,7 +62,8 @@ through your client's plugin manager (see [Optional native plugins](../integrations.md#optional-native-plugins)). The skill tells the agent to run `hack agent onboard` and follow the printed prompt. Without shell access it fetches the `hack-init` MCP prompt only when a Hack MCP server, such -as the opt-in `hack-mcp` plugin, is installed; otherwise it reports that blocker. +as the opt-in `hack-mcp` plugin, is installed, and passes `repoRoot` so the prompt +includes the repo inventory; otherwise it reports that blocker. The content stays in the CLI, so the skill fetches guidance matching the installed CLI. diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index 6f94c3479..d3dc3b994 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -163,7 +163,7 @@ Agent setup (plugin-first): - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index 6f94c3479..d3dc3b994 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -6,7 +6,7 @@ Use `hack` as the single interface for local-first runtime orchestration (compos Integration freshness: - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). Product boundary: - Supported CLI surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, and daemon. @@ -163,7 +163,7 @@ Agent setup (plugin-first): - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/plugins/hack/rules/hack.mdc b/plugins/hack/rules/hack.mdc index 168845512..e2267af61 100644 --- a/plugins/hack/rules/hack.mdc +++ b/plugins/hack/rules/hack.mdc @@ -14,7 +14,7 @@ Prefer `hack` when shell access is available. Use MCP only when shell access is - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). ## Product boundary diff --git a/plugins/hack/skills/hack-branches/SKILL.md b/plugins/hack/skills/hack-branches/SKILL.md index c508938b6..56d11ce56 100644 --- a/plugins/hack/skills/hack-branches/SKILL.md +++ b/plugins/hack/skills/hack-branches/SKILL.md @@ -35,4 +35,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-cli/SKILL.md b/plugins/hack/skills/hack-cli/SKILL.md index 069562770..e06dd1cff 100644 --- a/plugins/hack/skills/hack-cli/SKILL.md +++ b/plugins/hack/skills/hack-cli/SKILL.md @@ -16,7 +16,7 @@ Use `hack` as the primary interface for local-first development. - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). ## Product boundary @@ -194,7 +194,7 @@ Use `hack` as the primary interface for local-first development. - MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect. - Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client. - Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them. -- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. +- Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory. - Init prompt: `hack agent init` (use --client cursor|claude|codex to open) - Init patterns: `hack agent patterns` diff --git a/plugins/hack/skills/hack-diagnose/SKILL.md b/plugins/hack/skills/hack-diagnose/SKILL.md index 5eb98da54..f0b02fc71 100644 --- a/plugins/hack/skills/hack-diagnose/SKILL.md +++ b/plugins/hack/skills/hack-diagnose/SKILL.md @@ -37,7 +37,7 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). ## References diff --git a/plugins/hack/skills/hack-env/SKILL.md b/plugins/hack/skills/hack-env/SKILL.md index d804b55e1..034b2a974 100644 --- a/plugins/hack/skills/hack-env/SKILL.md +++ b/plugins/hack/skills/hack-env/SKILL.md @@ -34,4 +34,4 @@ description: > - These instructions were generated by hack CLI v4.2.1; use the content revision and command behavior to assess freshness. - Check guidance only when Hack is relevant and there is evidence of drift; use targeted `hack setup` checks for a read-only inventory. Routine tasks do not require a startup audit. -- Content revision: `f4120f477794` (version alone is not a freshness guarantee). +- Content revision: `adf8638d898d` (version alone is not a freshness guarantee). diff --git a/plugins/hack/skills/hack-init/SKILL.md b/plugins/hack/skills/hack-init/SKILL.md index 1b186c461..74638c097 100644 --- a/plugins/hack/skills/hack-init/SKILL.md +++ b/plugins/hack/skills/hack-init/SKILL.md @@ -12,6 +12,6 @@ Fetch the current onboarding guidance from the installed Hack CLI. 1. Fetch the canonical onboarding prompt: - With shell access: run `hack agent onboard` (works for new and existing projects). - - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. + - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server. 2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership. 3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion. diff --git a/src/agents/hack-init-skill.ts b/src/agents/hack-init-skill.ts index f69062811..777156464 100644 --- a/src/agents/hack-init-skill.ts +++ b/src/agents/hack-init-skill.ts @@ -74,7 +74,7 @@ export function renderHackInitSkill(): string { "", "1. Fetch the canonical onboarding prompt:", " - With shell access: run `hack agent onboard` (works for new and existing projects).", - " - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server.", + " - Without shell access: only if the `hack-mcp` plugin or another Hack MCP server is installed, fetch the `hack-init` MCP prompt from it and pass `repoRoot` (the repository root path) so the prompt includes Hack's repo inventory; otherwise report that onboarding needs shell access or an explicitly selected Hack MCP server.", "2. Apply the relevant phases to the authorized workflow; preserve native approvals, credential safety, and runtime ownership.", "3. Verify the requested services and host workflows. Report remaining blockers; unrelated optional integration warnings do not prevent completion.", "", diff --git a/src/agents/instruction-source.ts b/src/agents/instruction-source.ts index 7ef0b7ff5..2b7550aa2 100644 --- a/src/agents/instruction-source.ts +++ b/src/agents/instruction-source.ts @@ -358,7 +358,7 @@ export const INSTRUCTION_SECTIONS: readonly InstructionSection[] = [ "MCP is opt-in, only for clients without shell access and only when explicitly selected: add the separate `hack-mcp` plugin, or use `hack setup mcp` (client and scope via `hack mcp install --help`). Do not configure other clients as a side effect.", "Standalone export is an opt-in alternative to the plugin: `hack setup cursor` (rules), `hack setup claude` (hooks and skill), `hack setup codex` (skills) or `hack setup agents` (AGENTS.md/CLAUDE.md snippets). Use one integration path per client.", "Setup and onboarding never write AGENTS.md, CLAUDE.md, rule files, or repository skill copies unless the user asks for them.", - "Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content.", + "Agent-assisted onboarding: `hack init --with claude|codex` (new repos) or `hack agent onboard` (existing projects) print/hand off the full setup prompt; the `/hack-init` skill and the `hack-init` MCP prompt return the same content. No-shell clients pass `repoRoot` (the repository root path) to the `hack-init` MCP prompt to include Hack's repo inventory.", "Init prompt: `hack agent init` (use --client cursor|claude|codex to open)", "Init patterns: `hack agent patterns`", ], diff --git a/src/agents/integration-ownership.ts b/src/agents/integration-ownership.ts index fa8db2b83..ebb76e931 100644 --- a/src/agents/integration-ownership.ts +++ b/src/agents/integration-ownership.ts @@ -116,11 +116,13 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "eba974f66328605864507beb", // next: focused plugin skills + plugin-first agent setup "cf91ca873a00ad4743198e11", // next: plugin-first agent setup + refresh-only setup sync "0e99791aa0edfd1895e31748", // next: runtime metadata backend scope + refresh-only setup sync + "c6cd8f369e902624f9a31d2c", // next: hack-init MCP prompt takes repoRoot (HACK-1137) ]), "hack-init-skill": new Set([ "cd15fb6d8ed8e25071d05e75", // v3.1.0..v4.0.3 "f8e3018bcb8546e802dfa19c", // v4.1.0..next "dde7ffb2975e3257395f9506", // next: conditional hack-mcp fallback + "d8b5426211851f068d9a587b", // next: hack-init MCP prompt takes repoRoot (HACK-1137) ]), "cursor-rules": new Set([ "6acabe0f41a9785374970d6c", // v1.1.0..v1.10.2 @@ -154,6 +156,7 @@ const KNOWN_RENDER_FINGERPRINTS: Readonly< "270cd2b3fdcc325e04c76d43", // next: focused plugin skills + plugin-first agent setup "92ca4aa8f28ba1c93093c04a", // next: plugin-first agent setup + refresh-only setup sync "0b82281933af514de8a1b8b8", // next: runtime metadata backend scope + refresh-only setup sync + "5dcd202fc7bc640895bd876c", // next: hack-init MCP prompt takes repoRoot (HACK-1137) ]), }; diff --git a/src/agents/integration-revision.ts b/src/agents/integration-revision.ts index 5dfd1fdfe..7dc9bcca3 100644 --- a/src/agents/integration-revision.ts +++ b/src/agents/integration-revision.ts @@ -3,4 +3,4 @@ * source test recomputes this value and fails whenever guidance changes * without a revision update. */ -export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "f4120f477794"; +export const HACK_AGENT_INTEGRATION_CONTENT_REVISION = "adf8638d898d"; diff --git a/src/commands/project.ts b/src/commands/project.ts index ab283e780..2900fd414 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -27,7 +27,10 @@ import { text, } from "@clack/prompts"; import { YAML } from "bun"; -import { runInitAgentIntegrations } from "../agents/init-agent-integrations.ts"; +import { + type InitAgentIntegrationPrompts, + runInitAgentIntegrations, +} from "../agents/init-agent-integrations.ts"; import { type OnboardingWith, parseOnboardingWith, @@ -2774,13 +2777,51 @@ function validateInitProjectSlug( return undefined; } +/** + * Terminal seams for interactive `hack init`: the TTY check and every prompt + * the interactive path asks. Production uses {@link CLACK_INIT_IO}; tests + * inject scripted answers so the path, including the `--with` handoff, runs + * without a terminal. + */ +export type InitInteractiveIo = { + readonly canPrompt: () => boolean; + readonly text: (opts: { + readonly message: string; + readonly initialValue: string; + readonly validate: (value: string | undefined) => string | undefined; + }) => Promise; + readonly confirm: (opts: { + readonly message: string; + readonly initialValue: boolean; + }) => Promise; + /** + * Service wizard that renders `.hack/docker-compose.yml`: the discovered + * wizard when `useDiscovery`, otherwise the manual one. + */ + readonly composeWizard: (opts: { + readonly useDiscovery: boolean; + readonly input: ComposeWizardInput; + }) => Promise; + /** Coding-agent step prompts; omitted means the clack prompts. */ + readonly agentPrompts?: InitAgentIntegrationPrompts; +}; + +const CLACK_INIT_IO: InitInteractiveIo = { + canPrompt, + text: (opts) => text({ ...opts }), + confirm: (opts) => confirm({ ...opts }), + composeWizard: ({ useDiscovery, input }) => + useDiscovery ? buildDiscoveredCompose(input) : buildManualCompose(input), +}; + async function promptInitProjectSlug(opts: { + readonly io: InitInteractiveIo; readonly repoRoot: string; readonly nameOption: string | undefined; }): Promise { const defaultSlug = defaultProjectSlugFromPath(opts.repoRoot); const initialSlug = sanitizeProjectSlug(opts.nameOption ?? defaultSlug); - const name = await text({ + const name = await opts.io.text({ message: "Project name (slug):", initialValue: initialSlug, validate: validateInitProjectSlug, @@ -2843,13 +2884,14 @@ function validateInitDevHost(value: string | undefined): string | undefined { } async function promptInitDevHost(opts: { + readonly io: InitInteractiveIo; readonly slug: string; readonly devHostOption: string | undefined; readonly defaultDomain: string; }): Promise { const defaultHost = `${opts.slug}.${opts.defaultDomain}`; const initialHost = (opts.devHostOption ?? defaultHost).trim(); - const devHost = await text({ + const devHost = await opts.io.text({ message: "DEV_HOST:", initialValue: initialHost, validate: validateInitDevHost, @@ -2872,10 +2914,11 @@ function validateInitOauthTld(value: string | undefined): string | undefined { } async function promptInitOauthSettings(opts: { + readonly io: InitInteractiveIo; readonly oauthEnabledDefault: boolean; readonly oauthTldOption: string | undefined; }): Promise<{ readonly enabled: boolean; readonly tld: string } | null> { - const enableOauthHost = await confirm({ + const enableOauthHost = await opts.io.confirm({ message: `Enable OAuth-safe alias host (https://.${DEFAULT_PROJECT_TLD}.${DEFAULT_OAUTH_ALIAS_TLD})?`, initialValue: opts.oauthEnabledDefault, }); @@ -2887,7 +2930,7 @@ async function promptInitOauthSettings(opts: { return { enabled: false, tld: DEFAULT_OAUTH_ALIAS_TLD }; } - const oauthTld = await text({ + const oauthTld = await opts.io.text({ message: "OAuth alias TLD (optional):", initialValue: opts.oauthTldOption ?? DEFAULT_OAUTH_ALIAS_TLD, validate: validateInitOauthTld, @@ -2918,6 +2961,7 @@ function renderInitDiscoveryNote(opts: { } async function promptInitUseDiscovery(opts: { + readonly io: InitInteractiveIo; readonly canDiscover: boolean; readonly forceManual: boolean; }): Promise { @@ -2925,7 +2969,7 @@ async function promptInitUseDiscovery(opts: { return false; } - const useDiscovery = await confirm({ + const useDiscovery = await opts.io.confirm({ message: "Auto-discover dev scripts and generate services?", initialValue: true, }); @@ -2936,11 +2980,12 @@ async function promptInitUseDiscovery(opts: { } async function ensureInitHackDir(opts: { + readonly io: InitInteractiveIo; readonly hackDir: string; }): Promise<"proceed" | "skip" | null> { await assertLegacyProjectInputFamily({ projectRoot: dirname(opts.hackDir) }); if (await pathExists(opts.hackDir)) { - const ok = await confirm({ + const ok = await opts.io.confirm({ message: `${HACK_PROJECT_DIR_PRIMARY}/ already exists. Overwrite scaffold files?`, initialValue: false, }); @@ -2959,12 +3004,18 @@ async function ensureInitHackDir(opts: { return "proceed"; } -async function handleInit({ +/** + * `hack init`. `io` is the interactive path's terminal seam; the command + * handler omits it and gets the real TTY check and clack prompts. + */ +export async function handleInit({ ctx, args, + io = CLACK_INIT_IO, }: { readonly ctx: CliContext; readonly args: InitArgs; + readonly io?: InitInteractiveIo; }): Promise { const withValue = resolveInitWithOption({ withRaw: args.options.with }); const startDir = resolveStartDir(ctx, args.options.path); @@ -2977,7 +3028,7 @@ async function handleInit({ const repoRoot = await findRepoRootForInit(startDir); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); - if (!canPrompt()) { + if (!io.canPrompt()) { requireInteractive({ what: "hack init asks for project name, dev host, and discovery choices", hint: "Use `hack init --auto` (optionally with --name, --dev-host, --oauth, --oauth-tld, --manual) for scripted setup.", @@ -2985,6 +3036,7 @@ async function handleInit({ } const slug = await promptInitProjectSlug({ + io, repoRoot, nameOption: args.options.name, }); @@ -2995,6 +3047,7 @@ async function handleInit({ await ensureInitProjectSlugUnique({ repoRoot, slug }); const devHost = await promptInitDevHost({ + io, slug, devHostOption: args.options.devHost, defaultDomain: args.options.devHost @@ -3006,6 +3059,7 @@ async function handleInit({ } const oauth = await promptInitOauthSettings({ + io, oauthEnabledDefault: args.options.oauth === true || Boolean(args.options.oauthTld), oauthTldOption: args.options.oauthTld, @@ -3024,6 +3078,7 @@ async function handleInit({ } const useDiscovery = await promptInitUseDiscovery({ + io, canDiscover, forceManual, }); @@ -3035,7 +3090,7 @@ async function handleInit({ const composeFile = resolve(hackDir, PROJECT_COMPOSE_FILENAME); const configFile = resolve(hackDir, PROJECT_CONFIG_FILENAME); - const hackDirAction = await ensureInitHackDir({ hackDir }); + const hackDirAction = await ensureInitHackDir({ io, hackDir }); if (!hackDirAction) { return 1; } @@ -3049,26 +3104,23 @@ async function handleInit({ repoRoot, withValue, scaffold: "existing", + interactive: io.canPrompt(), }); } return 0; } await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); - const compose = useDiscovery - ? await buildDiscoveredCompose({ - repoRoot, - devHost, - projectSlug: slug, - candidates: discovery.candidates, - oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }) - : await buildManualCompose({ - repoRoot, - devHost, - projectSlug: slug, - oauth: { enabled: oauth.enabled, tld: oauth.tld }, - }); + const compose = await io.composeWizard({ + useDiscovery, + input: { + repoRoot, + devHost, + projectSlug: slug, + candidates: discovery.candidates, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, + }, + }); await assertLegacyProjectInputFamily({ projectRoot: repoRoot }); await ensureDir(hackDir); @@ -3130,8 +3182,9 @@ async function handleInit({ await runInitAgentIntegrations({ repoRoot, - interactive: canPrompt(), + interactive: io.canPrompt(), guard: assertLegacyProjectInputFamily, + ...(io.agentPrompts ? { prompts: io.agentPrompts } : {}), }); note( @@ -3155,6 +3208,7 @@ async function handleInit({ repoRoot, withValue, scaffold: "created", + interactive: io.canPrompt(), }); } @@ -3360,11 +3414,15 @@ function resolveInitWithOption(opts: { * Never spawns on non-interactive runs (`--no-interactive`, * `HACK_NO_INTERACTIVE`, or no TTY) — the prompt is printed for copy-paste * instead. Init success is never rolled back by handoff problems. + * + * @param opts.interactive - Defaults to the real TTY check; interactive init + * passes its injected {@link InitInteractiveIo.canPrompt} result. */ async function runInitOnboardingHandoff(opts: { readonly repoRoot: string; readonly withValue: OnboardingWith; readonly scaffold: "created" | "existing"; + readonly interactive?: boolean; }): Promise { await assertLegacyProjectInputFamily({ projectRoot: opts.repoRoot }); const prompt = await buildOnboardingPrompt({ @@ -3375,7 +3433,7 @@ async function runInitOnboardingHandoff(opts: { await runOnboardingHandoff({ prompt, withValue: opts.withValue, - interactive: canPrompt(), + interactive: opts.interactive ?? canPrompt(), }); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f5df3f362..2e6727362 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -401,9 +401,11 @@ function registerPrompts(opts: { }, }, async ({ repoRoot, mode, projectName, devHost }) => { - const prompt = repoRoot?.trim() + // Trim once so the presence check and the resolved path agree. + const requestedRoot = repoRoot?.trim(); + const prompt = requestedRoot ? await buildMcpOnboardingPrompt({ - startDir: resolve(opts.context.cwd, repoRoot), + startDir: resolve(opts.context.cwd, requestedRoot), }) : renderOnboardingPrompt({ context: onboardingContextWithoutInventory({ @@ -433,9 +435,12 @@ function registerPrompts(opts: { * Build the `hack-init` prompt for a caller-named repo. Failures map to a * closed, value-free protocol error: only the Hack error code survives; the * caller's path, config values and the underlying message are never echoed. + * + * @param opts.build - Test seam for the shared builder; production omits it. */ -async function buildMcpOnboardingPrompt(opts: { +export async function buildMcpOnboardingPrompt(opts: { readonly startDir: string; + readonly build?: typeof buildOnboardingPrompt; }): Promise { if (!(await pathExists(opts.startDir))) { const refusal = MCP_REFUSALS.path_not_found; @@ -444,11 +449,14 @@ async function buildMcpOnboardingPrompt(opts: { `${refusal.code}: ${refusal.message}` ); } + const request = { + startDir: opts.startDir, + entryPoint: MCP_ONBOARDING_ENTRY_POINT, + }; try { - return await buildOnboardingPrompt({ - startDir: opts.startDir, - entryPoint: MCP_ONBOARDING_ENTRY_POINT, - }); + return opts.build + ? await opts.build(request) + : await buildOnboardingPrompt(request); } catch (error) { const code: HackErrorCode = error instanceof HackCliError ? error.code : "E_UNEXPECTED"; diff --git a/tests/hack-init-skill.test.ts b/tests/hack-init-skill.test.ts index 10173d550..b11a5c7d2 100644 --- a/tests/hack-init-skill.test.ts +++ b/tests/hack-init-skill.test.ts @@ -77,6 +77,10 @@ test("renderHackInitSkill is a thin pointer to the CLI prompt source", () => { "only if the `hack-mcp` plugin or another Hack MCP server is installed" ); expect(skill).not.toContain("MCP prompt from the hack MCP server."); + // Without repoRoot the MCP prompt runs no inventory, so the skill asks for it. + expect(skill).toContain( + "fetch the `hack-init` MCP prompt from it and pass `repoRoot`" + ); // Thin: the full onboarding content must NOT be duplicated into the skill. expect(skill).not.toContain("node_modules:/app/node_modules"); expect(skill).not.toContain("## Phase 1"); diff --git a/tests/helpers/interactive-init-runner.ts b/tests/helpers/interactive-init-runner.ts new file mode 100644 index 000000000..1d6d61fad --- /dev/null +++ b/tests/helpers/interactive-init-runner.ts @@ -0,0 +1,62 @@ +import { CLI_SPEC } from "../../src/cli/spec.ts"; +import { + handleInit, + type InitInteractiveIo, +} from "../../src/commands/project.ts"; + +/** + * Child-process entry for the interactive `hack init` path with scripted + * terminal seams. The TTY check passes and every prompt takes its default: + * a fresh repo gets `.hack/` scaffolded (with a fixed, service-free compose + * in place of the service wizard), and an existing `.hack/` is kept because + * the overwrite confirm defaults to "no". No agent files are exported. Spawn it with `privateCliEnv` and a PATH without agent CLIs, so + * the interactive handoff prints its copy-paste fallback instead of launching. + * + * Usage: bun tests/helpers/interactive-init-runner.ts + */ + +const [repo, withValue] = process.argv.slice(2); +if (!(repo && withValue)) { + throw new Error("usage: interactive-init-runner.ts "); +} + +const io: InitInteractiveIo = { + canPrompt: () => true, + text: async ({ initialValue }) => initialValue, + confirm: async ({ initialValue }) => initialValue, + // The service wizard is out of scope here; a fixed compose keeps it inert. + composeWizard: async ({ input }) => + `name: ${input.projectSlug}\nservices: {}\n`, + agentPrompts: { + note: () => undefined, + confirm: async ({ initialValue }) => initialValue, + multiselect: async () => [], + }, +}; + +type InitInput = Parameters[0]; + +const args = { + options: { + path: repo, + manual: false, + auto: false, + name: undefined, + devHost: undefined, + oauth: false, + oauthTld: undefined, + noDiscovery: false, + with: withValue, + }, + positionals: {}, + raw: { + argv: ["init", "--path", repo, "--with", withValue], + positionals: [], + }, +} as unknown as InitInput["args"]; + +process.exitCode = await handleInit({ + ctx: { cwd: repo, cli: CLI_SPEC }, + args, + io, +}); diff --git a/tests/onboarding-entry-parity.test.ts b/tests/onboarding-entry-parity.test.ts index 2249d5235..bdb1efa62 100644 --- a/tests/onboarding-entry-parity.test.ts +++ b/tests/onboarding-entry-parity.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { collectOnboardingContext, @@ -12,7 +13,11 @@ import { renderOnboardingCore, renderOnboardingFrame, } from "../src/agents/onboarding-prompt.ts"; -import { createMcpServer } from "../src/mcp/server.ts"; +import { HackCliError } from "../src/lib/cli-result.ts"; +import { + buildMcpOnboardingPrompt, + createMcpServer, +} from "../src/mcp/server.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; /** @@ -25,6 +30,11 @@ import { privateCliEnv } from "./helpers/private-cli-env.ts"; */ const REPO_ROOT = resolve(import.meta.dir, ".."); +const INTERACTIVE_INIT_RUNNER = resolve( + import.meta.dir, + "helpers", + "interactive-init-runner.ts" +); const INIT_PROMPT_START = "----- hack onboarding prompt -----\n"; const INIT_PROMPT_END = "\n----- end prompt -----\n"; @@ -102,13 +112,38 @@ async function runCli( fixture: Fixture, args: readonly string[] ): Promise { - const child = Bun.spawn([process.execPath, "index.ts", ...args], { + return await runSourceEntry(fixture, ["index.ts", ...args], { + HACK_NO_INTERACTIVE: "1", + }); +} + +/** + * The interactive `hack init` path, run in a child with scripted terminal + * seams (tests/helpers/interactive-init-runner.ts) and a private environment. + */ +async function runInteractiveInit( + fixture: Fixture, + withValue: "claude" | "codex" +): Promise { + return await runSourceEntry(fixture, [ + INTERACTIVE_INIT_RUNNER, + fixture.repo, + withValue, + ]); +} + +async function runSourceEntry( + fixture: Fixture, + argv: readonly string[], + env: Readonly> = {} +): Promise { + const child = Bun.spawn([process.execPath, ...argv], { cwd: REPO_ROOT, env: privateCliEnv({ root: fixture.root, home: fixture.home, env: { - HACK_NO_INTERACTIVE: "1", + ...env, // No agent client binary can be found or launched. PATH: fixture.emptyPath, }, @@ -234,6 +269,13 @@ test("every entry point renders the same discovery-first core for the same repo" repoRoot: relative(fixture.root, fixture.repo), }), }, + // The trimmed value is both the presence check and the resolved path. + "mcp hack-init (padded relative repoRoot)": { + entryPoint: { kind: "mcp-hack-init" }, + text: await mcpPrompt(mcp, { + repoRoot: ` ${relative(fixture.root, fixture.repo)}\n`, + }), + }, }; expect(parityIssues({ core: freshCore, outputs: fresh })).toEqual([]); @@ -381,6 +423,112 @@ test("hack-init MCP refusals are closed and never echo the caller's path", async expect(nativeMessage).not.toContain(fixture.repo); }); +test("hack-init MCP inventory failures drop the underlying message, path and values", async () => { + const fixture = await createFixture(); + const leakedPath = join( + fixture.root, + "leaked-secret-dir", + "hack.config.json" + ); + const leakedValue = "DATABASE_URL=postgres://app:hunter2@db.internal/app"; + const cases = [ + { + label: "unexpected error", + error: new Error( + `EACCES: permission denied, open '${leakedPath}' (${leakedValue})` + ), + code: ErrorCode.InternalError, + text: "E_UNEXPECTED: hack-init could not inventory the repo.", + }, + { + label: "Hack error", + error: new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `Native project at ${leakedPath} uses ${leakedValue}`, + }), + code: ErrorCode.InvalidParams, + text: "E_NATIVE_PROJECT_UNSUPPORTED: hack-init could not inventory the repo.", + }, + ] as const; + + for (const testCase of cases) { + const requests: unknown[] = []; + const caught = await buildMcpOnboardingPrompt({ + startDir: fixture.repo, + build: (request) => { + requests.push(request); + return Promise.reject(testCase.error); + }, + }).then( + () => null, + (error: unknown) => error + ); + expect(requests, testCase.label).toEqual([ + { startDir: fixture.repo, entryPoint: { kind: "mcp-hack-init" } }, + ]); + expect(caught, testCase.label).toBeInstanceOf(McpError); + const error = caught as McpError; + expect(error.code, testCase.label).toBe(testCase.code); + // Exact text: any echo of the underlying message, path or value fails. + expect(error.message, testCase.label).toBe( + `MCP error ${testCase.code}: ${testCase.text}` + ); + expect(error.message).not.toContain("leaked-secret-dir"); + expect(error.message).not.toContain("hunter2"); + expect(error.data, testCase.label).toBeUndefined(); + } +}); + +test("an existing setup without a dev_host keeps the placeholder", async () => { + const fixture = await createFixture(); + await Bun.write( + join(fixture.repo, ".hack", "hack.config.json"), + `${JSON.stringify({ name: "parity" }, null, 2)}\n` + ); + await Bun.write( + join(fixture.repo, ".hack", "docker-compose.yml"), + "services: {}\n" + ); + + const context = await collectOnboardingContext({ startDir: fixture.repo }); + expect(context.mode).toBe("existing-project"); + expect(context.projectName).toBe("parity"); + expect(context.devHost).toBeUndefined(); + + const core = renderOnboardingCore({ context }); + expect(core).toContain("the primary app answers on dev_host ()"); + expect(core).toContain("`api.`"); + // The new-project default must not describe an adopted setup. + expect(core).not.toContain(".hack.local"); +}); + +test("interactive hack init frames the handoff by whether this run scaffolded .hack/", async () => { + const fixture = await createFixture(); + const created = await runInteractiveInit(fixture, "claude"); + const existing = await runInteractiveInit(fixture, "codex"); + + // The scripted TTY seam took the interactive handoff branch, which falls + // back to printing only because no agent CLI is on PATH. + expect(created).toContain("claude not found on PATH"); + expect(existing).toContain("codex not found on PATH"); + + const core = await expectedCore(fixture.repo); + expect(core).toContain("adopt the existing hack setup in this repo"); + const scaffolded = renderOnboardingFrame({ + entryPoint: { kind: "init-with", scaffold: "created" }, + }); + const kept = renderOnboardingFrame({ + entryPoint: { kind: "init-with", scaffold: "existing" }, + }); + expect(scaffolded.before).not.toBe(kept.before); + expect(extractInitPrompt(created)).toBe( + `${scaffolded.before}${core}${scaffolded.after}` + ); + expect(extractInitPrompt(existing)).toBe( + `${kept.before}${core}${kept.after}` + ); +}, 60_000); + test("negative control: a drifted entry point fails parity", async () => { const fixture = await createFixture(); const core = await expectedCore(fixture.repo);