From 48e793e2372e33c50f6b7d3b9e4a562340160ac6 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:05:56 +0000 Subject: [PATCH 01/44] feat(autostart): start opted-in Compose projects once per boot --- docs/always-on-projects.md | 118 +++++ docs/cli.md | 1 + docs/diagrams/autostart-login.mmd | 27 + docs/diagrams/autostart-login.svg | 66 +++ docs/reference/cli.md | 146 ++++++ docs/reference/v5-model-coverage.md | 1 + docs/versions/v5/getting-started.md | 5 + docs/versions/v5/index.md | 1 + src/cli/spec.ts | 2 + src/commands/autostart.ts | 211 ++++++++ src/commands/doctor.ts | 34 ++ src/commands/project.ts | 166 +++++- src/commands/projects.ts | 100 +++- src/daemon/autostart.ts | 571 +++++++++++++++++++++ src/daemon/server.ts | 15 + src/lib/child-environment.ts | 9 +- src/lib/keychain.ts | 8 + src/lib/native-authored-command.ts | 23 +- src/lib/native-compose-adoption-command.ts | 2 +- src/lib/native-compose-command.ts | 7 + src/lib/project-views.ts | 30 ++ src/lib/projects-registry.ts | 328 +++++++++++- tests/autostart-cli.test.ts | 246 +++++++++ tests/autostart-keychain.test.ts | 55 ++ tests/autostart-lifecycle-intent.test.ts | 176 +++++++ tests/child-environment.test.ts | 9 + tests/daemon-autostart.test.ts | 425 +++++++++++++++ tests/project-autostart.test.ts | 371 +++++++++++++ tests/status-native.test.ts | 51 +- 29 files changed, 3188 insertions(+), 16 deletions(-) create mode 100644 docs/always-on-projects.md create mode 100644 docs/diagrams/autostart-login.mmd create mode 100644 docs/diagrams/autostart-login.svg create mode 100644 src/commands/autostart.ts create mode 100644 src/daemon/autostart.ts create mode 100644 tests/autostart-cli.test.ts create mode 100644 tests/autostart-keychain.test.ts create mode 100644 tests/autostart-lifecycle-intent.test.ts create mode 100644 tests/daemon-autostart.test.ts create mode 100644 tests/project-autostart.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md new file mode 100644 index 000000000..bbac87aa3 --- /dev/null +++ b/docs/always-on-projects.md @@ -0,0 +1,118 @@ +# Always-on projects + +Autostart is a choice for this machine. A repository cannot enable it. Install +Hack's per-user login service, then mark a registered project: + +```sh +hack daemon install +hack autostart add sample +hack autostart add sample --branch preview +hack autostart list --json +``` + +Each project has one selected instance: base by default, or the named branch. +Adding it again replaces that selection. In the project directory, omit `sample`. +Opt in from the primary checkout. Linked-worktree opt-in is refused in this slice: +a branch instance uses the primary checkout's code, not a different Git checkout. +`hack up -d --always` marks the selected instance and starts it now. `add` only +changes the marker; it does not start anything immediately. + +`hack down` pauses autostart for the selected marked instance before attempting to +stop it. A later `hack up` clears the pause. Pausing does not stop running services: + +```sh +hack autostart pause sample +hack autostart resume sample +hack autostart pause # all marked projects +hack autostart resume # all marked projects +hack autostart remove sample # forget the marker; leave running services alone +``` + +## At login + +![Login startup sequence](diagrams/autostart-login.svg) + +The pass runs at the first `hackd` start in each OS boot. The installed service +normally starts it at login; starting the daemon manually or through a +daemon-backed CLI command can also trigger it. This slice supports Compose +projects on Linux and macOS. Native-runtime startup is skipped as +`native_unsupported` before probing Docker. + +The daemon waits up to 60 seconds for Docker, starts global routing only if it is +already installed, then considers unpaused projects in name/ID order. Starts are +at least 500 milliseconds apart, with at most two in progress. Each uses the +running daemon's CLI executable and the ordinary `up -d --no-interactive` path. +Already-running instances are adopted without repeating startup hooks. An unknown +runtime state is skipped rather than taken as proof that the instance is down. + +No terminal or keychain prompt is allowed. Missing keys, required interaction, +pending env migration, unavailable project paths and startup failures produce a +recorded result. Run `hack up` manually to investigate and recover. The daemon does +not install routing, start Docker, sign in, or migrate configuration at login. + +A claim is saved before each attempt, once per OS boot. Restarting the daemon in +the same boot does not repeat starts. A crash after the claim can leave an +interrupted attempt; its outcome is unknown, so this slice requires manual +`hack up` recovery. A new boot permits a new attempt. Resume permits the next login +attempt; use `hack up` to start immediately. +An engine timeout or routing failure also consumes that boot's attempt. If Docker +is slow to become ready, start the projects with `hack up` after it is ready. + +`HACK_DAEMON_AUTOSTART_DISABLE=1` disables this pass as well as implicit daemon +startup. Test harnesses and the isolated `hack-v5` launcher set it; those runs do +not start marked projects. Use the installed CLI/service for login startup. + +This first slice starts projects once. It does not restart a project after failure +or replace terminal-based lifecycle supervision. Persistent host helpers retain +the existing [lifecycle](lifecycle.md) behavior. Linux's systemd user service starts +at login; startup before login requires separately opting into user lingering. +No system service or `sudo` is needed for Hack's login service. + +## JSON contract + +`hack projects --json` and `hack status --json` add `autostart` to every project, +including summary views. It is `null` for unmarked projects. `hack autostart list +--json` returns `{ "projects": [...] }` containing only marked registrations; each +entry has `project_id`, `name`, and the same `autostart` object: + +```json +{ + "project_id": "example-id", + "name": "sample", + "autostart": { + "branch": null, + "paused": false, + "last_result": { + "status": "skipped", + "reason": "engine_timeout", + "at": "2026-01-01T00:00:00.000Z" + } + } +} +``` + +`branch` is `null` for base or a branch-instance slug. `paused` is a boolean. +`last_result` is `null` before any recorded attempt, otherwise its `status` is +`started`, `already_running`, `skipped`, or `failed`; `reason` is `null` on success +or a machine-readable code; `at` is an ISO 8601 timestamp. Reasons never contain +command output, environment values or credentials. Consumers should display an +unknown future reason as a generic failure and retain its code. These fields are +additive and stable; internal boot claims are not part of this public contract. + +Current skip reasons are `engine_timeout`, `routing_failed`, +`boot_identity_unavailable`, `native_unsupported`, `runtime_unknown`, +`interactive_required`, `env_key_missing`, `keychain_unavailable`, +`pending_env_migration`, `project_unavailable`, `paused`, `target_changed`, +`daemon_stopped`, and `invalid_marker`. Failure reasons are `start_failed`, +`start_timeout`, and `output_limit`. + +An invalid or newer marker is shown as paused with `skipped/invalid_marker` and +the sentinel timestamp `1970-01-01T00:00:00.000Z` (no attempt occurred). Other +project commands keep working and unrelated registry writes preserve the original +marker. Remove and re-add it with `hack autostart remove sample` then +`hack autostart add sample` to repair it. Unknown future statuses fail closed in +this version rather than enabling startup. + +`hack doctor` warns when a project is marked but no login-service file exists and +names `hack daemon install`. File presence alone does not prove a real login run +or that the operating system enabled the service. diff --git a/docs/cli.md b/docs/cli.md index 149ff4c25..cfa967aae 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -7,6 +7,7 @@ terminal). ## Core commands +- `hack autostart add|remove|list|pause|resume` — choose [always-on projects](always-on-projects.md) for this machine; `hack up -d --always` marks and starts one. `down` pauses its next login start; `up` resumes it. - `hack init` — generate `.hack/` (compose + config); `--with claude|codex` also hands off to agent-assisted onboarding - `hack up` / `hack down` / `hack restart` — start, stop and restart the project diff --git a/docs/diagrams/autostart-login.mmd b/docs/diagrams/autostart-login.mmd new file mode 100644 index 000000000..3111ea316 --- /dev/null +++ b/docs/diagrams/autostart-login.mmd @@ -0,0 +1,27 @@ +--- +title: Autostart at login +--- +sequenceDiagram + autonumber + participant Login + participant Daemon + participant Engine + participant Registry + participant CLI + Login->>Daemon: Start user service + Daemon->>Registry: Read machine markers + Daemon->>Engine: Wait up to 60 seconds + opt Routing already installed + Daemon->>CLI: global up, no interaction + end + loop Unpaused projects in stable order + Daemon->>Registry: Claim once per boot + Daemon->>Engine: Inspect selected instance + alt Confirmed stopped + Daemon->>CLI: up -d, no interaction + CLI-->>Daemon: Result without prompts + else Running or unknown + Daemon->>Daemon: Adopt or skip + end + Daemon->>Registry: Save result and reason + end diff --git a/docs/diagrams/autostart-login.svg b/docs/diagrams/autostart-login.svg new file mode 100644 index 000000000..e628efb13 --- /dev/null +++ b/docs/diagrams/autostart-login.svg @@ -0,0 +1,66 @@ +Autostart at loginopt[Routing already installed][Running or unknown]alt[Confirmed stopped]loop[Unpaused projects in stable order]12345678910Start user serviceRead machine markersWait up to 60 secondsglobal up, no interactionClaim once per bootInspect selected instanceup -d, no interactionResult without promptsAdopt or skipSave result and reasonLoginDaemonEngineRegistryCLILoginDaemonEngineRegistryCLI \ No newline at end of file diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 46880255c..0309d326b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8,6 +8,151 @@ Run `hack help ` for the same content in the terminal. Global options: ## Project +## `hack autostart` + +Manage always-on projects on this machine + +### Usage + +```bash +hack autostart [options] +``` + +### Subcommands + +| Command | Summary | +| --- | --- | +| `hack autostart add [project]` | Start this registered project at login on this machine | +| `hack autostart remove [project]` | Remove this project's machine autostart marker | +| `hack autostart list` | List this machine's always-on projects | +| `hack autostart pause [project]` | Pause one marked project, or all marked projects | +| `hack autostart resume [project]` | Resume one marked project, or all marked projects | + +### Options + +| Option | Description | +| --- | --- | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + +## `hack autostart add [project]` + +Start this registered project at login on this machine + +### Usage + +```bash +hack autostart add [project] [options] +``` + +### Arguments + +| Arg | Description | +| --- | --- | +| `project` | | + +### Options + +| Option | Description | +| --- | --- | +| `--branch ` | Run against a branch-specific instance (compose name + hostnames) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + +## `hack autostart remove [project]` + +Remove this project's machine autostart marker + +### Usage + +```bash +hack autostart remove [project] [options] +``` + +### Arguments + +| Arg | Description | +| --- | --- | +| `project` | | + +### Options + +| Option | Description | +| --- | --- | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + +## `hack autostart list` + +List this machine's always-on projects + +### Usage + +```bash +hack autostart list [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--json` | Output JSON (machine-readable) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + +## `hack autostart pause [project]` + +Pause one marked project, or all marked projects + +### Usage + +```bash +hack autostart pause [project] [options] +``` + +### Arguments + +| Arg | Description | +| --- | --- | +| `project` | | + +### Options + +| Option | Description | +| --- | --- | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + +## `hack autostart resume [project]` + +Resume one marked project, or all marked projects + +### Usage + +```bash +hack autostart resume [project] [options] +``` + +### Arguments + +| Arg | Description | +| --- | --- | +| `project` | | + +### Options + +| Option | Description | +| --- | --- | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + + ## `hack project` Inspect or manage project metadata @@ -187,6 +332,7 @@ hack up [services...] [options] | `--profile ` | Enable one or more compose profiles (comma-separated) | | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | +| `--always` | Mark this project for machine-local autostart and start it | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index d96eb13a3..cf467fdee 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,6 +36,7 @@ must be attached to the eventual exact integration revision. | Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification | | --- | --- | --- | --- | +| Machine autostart intent, pause, per-boot claim and startup replay | No TLA+ model in the once-per-login slice; registry lock is the atomic claim boundary | `projects-registry.ts` persists claim before dispatch; `daemon/autostart.ts` rechecks pause and target after observation. `project-autostart.test.ts`, `autostart-lifecycle-intent.test.ts` and `daemon-autostart.test.ts` cover old registries, pause/readback, concurrent claims, restart suppression, stable order, stagger and cap | At most two starts; 60-second engine wait; one claim per project/boot. Crash after claim may skip an attempt. A pause after the final recheck can overlap an already-dispatched start; no distributed cancellation proof. Real Linux/macOS login, kernel scheduling, keychain behavior and terminal-independent lifecycle supervision remain external qualification. | | Native status across startup lease stages; failed start with running services (HACK-2110) | [native-status-start](../../tests/models/tla/native-status-start/COVERAGE.md) | Observer-only presentation, stable journal fence, distinct authored Observe/Status replies; real-flock held-reader start test and expiry diagnostic; plain-down exact-proof recovery tests. Failure capture occurs at startup/effect verification and owned cleanup, never status; first failure wins, incomplete-rebind capture and owner-crash reconstruction are excluded | Seven acquisition stages, one reader, one start; 110 states. Negative control fails after services start. Second-mutator refusal and complete recovery proof are assumptions checked separately in code, not independently proved by this model. No new control-loop tick or lease; cleanup settle is bounded at ten seconds per exited listener. Lead Mac gate: ps every 0.5 s across ten starts, cross-project polling and failed-start plain down. | | Guest control ready before exposed bridge sockets bind; identity changes or the startup deadline expires | No new TLA+ model; deterministic clock schedules both readiness orders against real Unix socket nodes | `provider/bridge.rs` waits only in fresh `finish_boot`, before adoption, and keeps running audits immediate. `bridge_startup_tests.rs` covers two slots, late creation and mode tightening, permanent absence/unsafe modes, type/UID/link refusals, provider loss before/after observation and late success rejection. No wait mutates sockets or grants cleanup authority; the existing verified-provider stop handles failure. `lifecycle/boot_sequence_tests.rs` drives the real waiter through the production fresh-boot sequence and rejects audit/adoption on timeout, unsafe sockets or provider loss. `native-runtime-client.test.ts` checks the fixed runtime recover command and identical-root hint reach the frontend | Two sockets, finite scheduled events, injected provider verification and monotonic clock. Kernel process identity, provider scheduling, disk durability and successful recovery remain host boundaries. Require ten consecutive real-VM Mac Ctrl-C scenarios with pool stop/start; portable tests are not that evidence. | | CI cancellation must not lose merged-change coverage; only exact-head full evidence may qualify a prerelease | No maintained GitHub scheduler model. Finite fixture interleavings cover predecessor success/failure/cancellation, two competing full/scoped runs and two rerun attempts | `ci.yml` serializes next pushes in one non-cancelling group (GitHub cancels only the replaced pending run), cancels stale PR runs, and requires a successful before run for selection. `selectPrereleaseCiRun` reads current attempts; `requireBranchChecks` binds suite and job check IDs. `ci-trusted-policy`, `prerelease-plan` and `release-prepare-gate` regressions reject cancelled predecessors, green subsets, stale checks, skipped hosts and changed attempts | API snapshots and GitHub group/event semantics are assumptions; races after the last read remain possible. Publication rechecks the frozen protected head and full evidence but does not transactionally lock GitHub. Hosted dispatch/cancellation checks qualify wiring; no scheduler liveness or formal refinement claim. | diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 40d949f31..9b7537684 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -63,6 +63,11 @@ hack ps hack open ``` +To start this project at future logins, use `hack up -d --always` and install +the per-user daemon with `hack daemon install`. `hack down` pauses its marker; +`hack up` resumes it. See [Always-on projects](https://github.com/hack-dance/hack/blob/next/docs/always-on-projects.md) for +the startup bounds, no-prompt behavior and JSON contract. + Expect the declared services in `ps` and the app's local URL from `open`. If a project has several URLs, choose the app's route. A worker-only project may have no URL. Sign in or load a page that reads data to check the app itself. diff --git a/docs/versions/v5/index.md b/docs/versions/v5/index.md index 11e2ffa6b..9dbf0e4e4 100644 --- a/docs/versions/v5/index.md +++ b/docs/versions/v5/index.md @@ -34,6 +34,7 @@ v5 prerelease bundles are Apple Silicon macOS only; see ## Do a task +- [Start projects at login](https://github.com/hack-dance/hack/blob/next/docs/always-on-projects.md) - [Set up a Linux host](../guides/linux-setup.md): local DNS, trust and the daemon. - [Configure Linux certificate trust](../guides/linux-certificate-trust.md): distribution and browser trust stores. - [Upgrade a v4 machine to v5](guides/upgrade-from-v4.md): preparation, rollback and checks awaiting the dogfood run. diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 063875158..d95b7a3f6 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -1,5 +1,6 @@ import pkg from "../../package.json"; import { agentCommand } from "../commands/agent.ts"; +import { autostartCommand } from "../commands/autostart.ts"; import { branchCommand } from "../commands/branch.ts"; import { commandsCommand } from "../commands/commands.ts"; import { configCommand } from "../commands/config.ts"; @@ -70,6 +71,7 @@ export const CLI_SPEC = defineCli({ statusCommand, usageCommand, projectsCommand, + autostartCommand, projectCommand, initCommand, devCommand, diff --git a/src/commands/autostart.ts b/src/commands/autostart.ts new file mode 100644 index 000000000..e0a12fc99 --- /dev/null +++ b/src/commands/autostart.ts @@ -0,0 +1,211 @@ +import { relative, resolve } from "node:path"; +import { + type CliContext, + CliUsageError, + type CommandHandlerFor, + defineCommand, + withHandler, +} from "../cli/command.ts"; +import { optBranch, optJson } from "../cli/options.ts"; +import { + parseProjectSelectorOption, + unknownProjectMessage, +} from "../cli/project-selector.ts"; +import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; +import { sanitizeBranchSlug } from "../lib/project.ts"; +import { PROJECT_SELECTOR_PATTERN } from "../lib/project-name.ts"; +import { serializeProjectAutostart } from "../lib/project-views.ts"; +import { + type RegisteredProject, + readProjectsRegistry, + removeProjectAutostart, + selectRegisteredProjectByName, + setProjectAutostart, + setProjectAutostartPaused, +} from "../lib/projects-registry.ts"; +import { display } from "../ui/display.ts"; + +const projectPositionals = [{ name: "project", required: false }] as const; +const addSpec = defineCommand({ + name: "add", + summary: "Start this registered project at login on this machine", + group: "Project", + options: [optBranch], + positionals: projectPositionals, + subcommands: [], +} as const); +const removeSpec = defineCommand({ + name: "remove", + summary: "Remove this project's machine autostart marker", + group: "Project", + options: [], + positionals: projectPositionals, + subcommands: [], +} as const); +const pauseSpec = defineCommand({ + name: "pause", + summary: "Pause one marked project, or all marked projects", + group: "Project", + options: [], + positionals: projectPositionals, + subcommands: [], +} as const); +const resumeSpec = defineCommand({ + name: "resume", + summary: "Resume one marked project, or all marked projects", + group: "Project", + options: [], + positionals: projectPositionals, + subcommands: [], +} as const); +const listSpec = defineCommand({ + name: "list", + summary: "List this machine's always-on projects", + group: "Project", + options: [optJson], + positionals: [], + subcommands: [], +} as const); + +/** Registry selection changes only the machine marker, without reading project configuration. */ +async function selectProject(opts: { + readonly ctx: CliContext; + readonly name?: string; +}): Promise { + const selector = + opts.name === undefined + ? null + : parseProjectSelectorOption({ projectOpt: opts.name }); + const registry = await readProjectsRegistry(); + if (selector) { + const project = selectRegisteredProjectByName({ + projects: registry.projects, + name: selector.name, + }); + if (!project) { + throw new CliUsageError(unknownProjectMessage(selector)); + } + return project; + } + const cwd = resolve(opts.ctx.cwd); + const matches = registry.projects + .filter((project) => { + const roots = [ + project.repoRoot, + ...(project.worktrees ?? []).map((worktree) => worktree.path), + ]; + return roots.some((root) => { + const path = relative(root, cwd); + return ( + path === "" || + !( + path === ".." || + path.startsWith("../") || + resolve(root, path) !== cwd + ) + ); + }); + }) + .sort((a, b) => b.repoRoot.length - a.repoRoot.length); + const project = matches[0]; + if (!project || (matches[1] && matches[1].repoRoot === project.repoRoot)) { + throw new CliUsageError( + "Select a registered project by name; run 'hack projects' to list projects." + ); + } + return project; +} + +const add: CommandHandlerFor = async ({ ctx, args }) => { + const project = await selectProject({ ctx, name: args.positionals.project }); + const [cwdLinked, registeredLinked] = await Promise.all([ + isLinkedGitWorktree({ repoRoot: ctx.cwd }), + isLinkedGitWorktree({ repoRoot: project.repoRoot }), + ]); + if (cwdLinked === true || registeredLinked === true) { + throw new CliUsageError( + "Autostart currently requires the primary checkout; run 'hack autostart add --branch ' there." + ); + } + const branch = + args.options.branch === undefined + ? undefined + : sanitizeBranchSlug(args.options.branch); + if ( + args.options.branch !== undefined && + !(PROJECT_SELECTOR_PATTERN.test(args.options.branch.trim()) && branch) + ) { + throw new CliUsageError( + "Invalid --branch value; use a branch alias containing letters or digits." + ); + } + await setProjectAutostart({ id: project.id, branch }); + process.stdout.write( + "Autostart enabled on this machine. Install the login service with: hack daemon install\n" + ); + return 0; +}; +const remove: CommandHandlerFor = async ({ ctx, args }) => { + const project = await selectProject({ ctx, name: args.positionals.project }); + await removeProjectAutostart({ id: project.id }); + process.stdout.write("Autostart removed on this machine.\n"); + return 0; +}; +const pause: CommandHandlerFor = async ({ ctx, args }) => { + const project = + args.positionals.project === undefined + ? null + : await selectProject({ ctx, name: args.positionals.project }); + await setProjectAutostartPaused({ id: project?.id, paused: true }); + process.stdout.write("Autostart paused; running projects are unchanged.\n"); + return 0; +}; +const resume: CommandHandlerFor = async ({ ctx, args }) => { + const project = + args.positionals.project === undefined + ? null + : await selectProject({ ctx, name: args.positionals.project }); + await setProjectAutostartPaused({ id: project?.id, paused: false }); + process.stdout.write( + "Autostart resumed; use 'hack up -d' to start a project now.\n" + ); + return 0; +}; +const list: CommandHandlerFor = async ({ args }) => { + const registry = await readProjectsRegistry(); + const projects = registry.projects + .filter((project) => project.autostart) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + if (args.options.json) { + process.stdout.write( + `${JSON.stringify({ projects: projects.map((project) => ({ project_id: project.id, name: project.name, autostart: serializeProjectAutostart(project.autostart) })) })}\n` + ); + return 0; + } + await display.table({ + columns: ["Project", "Branch", "Autostart", "Last result", "Reason"], + rows: projects.map((project) => [ + project.name, + project.autostart?.branch ?? "base", + project.autostart?.paused ? "paused" : "always-on", + project.autostart?.lastResult?.status ?? "not attempted", + project.autostart?.lastResult?.reason ?? "", + ]), + }); + return 0; +}; + +export const autostartCommand = defineCommand({ + name: "autostart", + summary: "Manage always-on projects on this machine", + group: "Project", + options: [], + positionals: [], + subcommands: [ + withHandler(addSpec, add), + withHandler(removeSpec, remove), + withHandler(listSpec, list), + withHandler(pauseSpec, pause), + withHandler(resumeSpec, resume), + ], +} as const); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 21f4bd3be..b6e0856c3 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -878,6 +878,11 @@ const handleDoctor: CommandHandlerFor = async ({ // Global files results.push(await runCheck(s, "global files", () => checkGlobalFiles())); results.push(await runCheck(s, "daemon", () => checkDaemonStatus())); + results.push( + await runCheck(s, "autostart login service", () => + checkAutostartLoginService() + ) + ); if (isLinux() && !isCandidateRun()) { // Candidate trials do not manage the stable login service. Otherwise the // row is omitted when systemd management is disabled for this process @@ -1939,6 +1944,35 @@ async function checkGlobalFiles(): Promise { }; } +/** File-only observation: never starts the daemon or invokes a service manager. */ +async function checkAutostartLoginService(): Promise { + const registry = await readProjectsRegistry(); + const marked = registry.projects.filter( + (project) => project.autostart + ).length; + if (marked === 0) { + return { + name: "autostart login service", + status: "ok", + message: "No always-on projects marked.", + }; + } + let unitPath: string | null = null; + if (isMac()) { + unitPath = resolveDaemonPaths({}).launchdPlistPath; + } else if (isLinux()) { + unitPath = resolveSystemdUnitPath(); + } + const installed = unitPath !== null && (await pathExists(unitPath)); + return { + name: "autostart login service", + status: installed ? "ok" : "warn", + message: installed + ? "Always-on projects have an installed login service." + : "Always-on projects are marked but the login service is not installed. Run: hack daemon install", + }; +} + async function checkDaemonStatus(): Promise { const paths = resolveDaemonPaths({}); const status = await readDaemonStatus({ paths }); diff --git a/src/commands/project.ts b/src/commands/project.ts index cd2a76425..9f9bda93d 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -21,6 +21,7 @@ export function wrapLifecyclePersistentCommand( return wrapSharedLifecyclePersistentCommand(opts); } +import { realpath } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import { autocompleteMultiselect, @@ -117,7 +118,10 @@ import { optTail, optUntil, } from "../cli/options.ts"; -import { resolveRegisteredProjectOption } from "../cli/project-selector.ts"; +import { + parseProjectSelectorOption, + resolveRegisteredProjectOption, +} from "../cli/project-selector.ts"; import { globalUp } from "../commands/global.ts"; import { DEFAULT_GRAFANA_HOST, @@ -245,6 +249,7 @@ import { writeTextFileIfChanged, } from "../lib/fs.ts"; import { ensureHackGitInfoExclude } from "../lib/git-info-exclude.ts"; +import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; import { resolveInstalledGrafanaHost } from "../lib/global-service-host.ts"; import { getString, isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; @@ -322,6 +327,7 @@ import { HACK_DIR_GITIGNORE_READ_ONLY_WARNING, hasUnmigratedLegacyDotenv, migrateLegacyProjectEnv, + ProjectEnvKeyMissingError, projectEnvConfigExists, resolveProjectEnvConfig, selectProjectEnvValuesForExecutionTarget, @@ -335,6 +341,7 @@ import { assertLegacyProjectDiscovery, assertLegacyProjectInputFamily, } from "../lib/project-input-selection.ts"; +import { normalizeProjectName } from "../lib/project-name.ts"; import { readProjectRuntimeStateEntry, removeProjectRuntimeStateEntry, @@ -361,6 +368,8 @@ import { } from "../lib/project-shared-helpers.ts"; import { readProjectsRegistry, + setProjectAutostart, + setProjectAutostartPaused, upsertProjectRegistration, } from "../lib/projects-registry.ts"; import { @@ -382,6 +391,7 @@ import { import { formatSecretStoreDescriptor, resolveSecretStore, + SecretStoreKeyUnavailableError, } from "../lib/secret-store.ts"; import { exec, findExecutableInPath, run as runShell } from "../lib/shell.ts"; import { assertComposeSpawnEnvFits } from "../lib/spawn-env-limit.ts"; @@ -567,6 +577,12 @@ const upOptions = [ optProfile, optTarget, optJson, + defineOption({ + name: "always", + type: "boolean", + long: "--always", + description: "Mark this project for machine-local autostart and start it", + } as const), ] as const; const downOptions = [ optPath, @@ -903,6 +919,10 @@ export async function resolveEffectiveBranchForCommand(opts: { if (explicit) { return explicit; } + // A saved null instance means base, even when its checkout is a worktree. + if (process.env.HACK_AUTOSTART_LOGIN === "1") { + return null; + } const cfg = await readProjectConfig(opts.project); const resolved = await resolveEffectiveBranch({ @@ -1141,7 +1161,20 @@ function emitLifecycleResult(opts: { return opts.exitCode; } -function buildLifecycleJsonErrorResult(opts: { readonly error: unknown }) { +export function buildLifecycleJsonErrorResult(opts: { + readonly error: unknown; +}) { + if ( + process.env.HACK_AUTOSTART_LOGIN === "1" && + (opts.error instanceof SecretStoreKeyUnavailableError || + opts.error instanceof ProjectEnvKeyMissingError) + ) { + return errorResult({ + code: "E_ENV_KEY_MISSING", + message: + "Autostart skipped: a required secret key is unavailable. Run hack up manually.", + }); + } if (opts.error instanceof CliUsageError) { return errorResult({ code: "E_USAGE", message: opts.error.message }); } @@ -2903,7 +2936,7 @@ export async function mergeLifecycleCommandEnv( ): Promise> { const base: Record = {}; for (const [key, value] of Object.entries(process.env)) { - if (typeof value === "string") { + if (typeof value === "string" && key !== "HACK_AUTOSTART_LOGIN") { base[key] = value; } } @@ -6362,6 +6395,126 @@ async function restartNativeImageProject({ }); } +/** Publish explicit user intent before runtime effects so a subsequent login respects down. */ +async function updateAutostartIntent(opts: { + readonly ctx: CliContext; + readonly action: "up" | "down"; + readonly args: { + readonly options: { + readonly path?: string; + readonly project?: string; + readonly branch?: string; + readonly always?: boolean; + }; + }; +}): Promise { + const login = process.env.HACK_AUTOSTART_LOGIN === "1"; + const registry = await readProjectsRegistry(); + const selector = + opts.args.options.project === undefined + ? null + : parseProjectSelectorOption({ projectOpt: opts.args.options.project }); + const requestedStartDir = resolveStartDir(opts.ctx, opts.args.options.path); + const startDir = await realpath(requestedStartDir).catch( + () => requestedStartDir + ); + const marked = registry.projects + .filter((entry) => entry.autostart) + .find((entry) => + selector + ? normalizeProjectName(entry.name) === selector.name + : [ + entry.repoRoot, + ...(entry.worktrees?.map((worktree) => worktree.path) ?? []), + ].some((path) => startDir === path || startDir.startsWith(`${path}/`)) + ); + if (!(login || opts.args.options.always || marked)) { + return; + } + const project = await resolveProjectForArgs({ + ctx: opts.ctx, + pathOpt: opts.args.options.path, + projectOpt: opts.args.options.project, + touchRegistration: false, + }); + if (login) { + // Login must never continue with a legacy environment that needs migration. + const contract = await pathExists( + resolve(project.projectDir, PROJECT_ENV_CONTRACT_FILENAME) + ); + const modern = await projectEnvConfigExists({ + projectDir: project.projectDir, + }); + const legacy = await findUnmigratedLegacyDotenv({ + projectRoot: project.projectRoot, + projectDir: project.projectDir, + serviceNames: await discoverComposeServiceNames({ + composeFile: project.composeFile, + }), + }); + const dotenv = Bun.file(resolve(project.projectDir, PROJECT_ENV_FILENAME)); + const legacyDotenv = !modern && (await dotenv.exists()) && dotenv.size > 0; + if ( + (!modern && contract) || + legacyDotenv || + hasUnmigratedLegacyDotenv(legacy) + ) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Autostart skipped: pending env migration. Run hack doctor --migrate-env-config.", + detail: { reason: "pending_env_migration" }, + }); + } + return; + } + // Inspect selection without printing the normal up/down worktree notice twice. + const cfg = await readProjectConfig(project); + const resolvedBranch = await resolveEffectiveBranch({ + explicitBranch: resolveBranchSlug(opts.args.options.branch), + projectRoot: project.projectRoot, + autoBranchEnabled: resolveWorktreeAutoBranch(cfg), + }); + if (resolvedBranch.source === "detached-worktree") { + throw new CliUsageError( + "Detached linked worktree requires an explicit --branch." + ); + } + const branch = resolvedBranch.branch; + if (opts.action === "up" && opts.args.options.always) { + if (await isLinkedGitWorktree({ repoRoot: project.projectRoot })) { + throw new CliUsageError( + "Autostart currently requires the primary checkout. Run hack up --always there with --branch ." + ); + } + const registered = await upsertProjectRegistration({ project }); + if (registered.status === "conflict") { + throw new CliUsageError( + "Project registration conflicts; inspect hack projects before enabling autostart." + ); + } + await setProjectAutostart({ id: registered.project.id, branch }); + return; + } + const projectDir = await realpath(project.projectDir).catch( + () => project.projectDir + ); + const projectRoot = await realpath(project.projectRoot).catch( + () => project.projectRoot + ); + const registered = registry.projects.find( + (entry) => + entry.projectDir === projectDir || + entry.worktrees?.some((worktree) => worktree.path === projectRoot) + ); + if (registered?.autostart && registered.autostart.branch === branch) { + await setProjectAutostartPaused({ + id: registered.id, + paused: opts.action === "down", + }); + } +} + async function handleUp({ ctx, args, @@ -6371,6 +6524,7 @@ async function handleUp({ }): Promise { // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); + await updateAutostartIntent({ ctx, args, action: "up" }); if (resolveNativeRuntimeSelection()) { // Once per `hack up`, whichever native start path follows (HACK-2026). noteNativeBackendBeta({ json: args.options.json === true }); @@ -7121,6 +7275,7 @@ async function handleDown({ }): Promise { // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); + await updateAutostartIntent({ ctx, args, action: "down" }); const authored = await tryNativeComposeCommand({ cwd: ctx.cwd, path: args.options.path, @@ -8392,7 +8547,10 @@ async function handleRestart({ if (native) { return await handleNativeUp({ ctx, - args: { ...args, options: { ...args.options, detach: false } }, + args: { + ...args, + options: { ...args.options, detach: false, always: false }, + }, native, restart: true, recovery: diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 72f0263a8..e74943440 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -25,6 +25,7 @@ import { resolveGlobalCaddyIp, } from "../lib/caddy-hosts.ts"; import { emitCliResult, okResult } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; import { confirmSafe } from "../lib/interactivity.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { tryNativeComposeCommand } from "../lib/native-compose-command.ts"; @@ -52,6 +53,7 @@ import { import type { ProjectView } from "../lib/project-views.ts"; import { buildProjectViews, + serializeProjectAutostart, serializeProjectSummary, serializeProjectView, } from "../lib/project-views.ts"; @@ -496,6 +498,7 @@ async function runNativeStatus(opts: { readonly json: boolean; readonly ps?: typeof nativeProjectPs; readonly authored?: typeof tryNativeComposeCommand; + readonly readRegistry?: typeof readProjectsRegistry; }): Promise { // Once, whichever native observer answers (HACK-2026). noteNativeBackendBeta({ json: opts.json }); @@ -507,6 +510,27 @@ async function runNativeStatus(opts: { project: opts.projectOpt, operation: "ps", json: opts.json, + statusMetadata: async () => { + const registry = await (opts.readRegistry ?? readProjectsRegistry)(); + const registration = + opts.projectOpt === undefined + ? registry.projects + .filter((entry) => + [ + entry.repoRoot, + ...(entry.worktrees ?? []).map((worktree) => worktree.path), + ].some( + (root) => opts.cwd === root || opts.cwd.startsWith(`${root}/`) + ) + ) + .sort((a, b) => b.repoRoot.length - a.repoRoot.length)[0] + : selectRegisteredProjectByName({ + projects: registry.projects, + name: parseProjectSelectorOption({ projectOpt: opts.projectOpt }) + .name, + }); + return { autostart: serializeProjectAutostart(registration?.autostart) }; + }, }); if (authored !== null) { return authored; @@ -541,7 +565,28 @@ async function runNativeStatus(opts: { branch, }, }); - await displayNativePs(result, opts.json); + const registry = await (opts.readRegistry ?? readProjectsRegistry)(); + const registration = registry.projects.find( + (entry) => + entry.projectDir === project.projectDir || + entry.worktrees?.some((worktree) => worktree.path === project.projectRoot) + ); + const autostart = serializeProjectAutostart(registration?.autostart); + if (opts.json) { + emitCliResult({ result: okResult({ data: { ...result, autostart } }) }); + } else { + await displayNativePs(result, false); + if (registration?.autostart) { + const policy = registration.autostart; + await display.kv({ + entries: [ + ["Autostart", policy.paused ? "paused" : "always-on"], + ["Last autostart", policy.lastResult?.status ?? "not attempted"], + ["Autostart reason", policy.lastResult?.reason ?? ""], + ], + }); + } + } return 0; } @@ -641,7 +686,15 @@ async function runProjects(opts: { await display.section("Projects"); await display.table({ - columns: ["Name", "Status", "Services", "Dev Host", "Repo Root"], + columns: [ + "Name", + "Status", + "Autostart", + "Last autostart", + "Services", + "Dev Host", + "Repo Root", + ], rows: views.map((p) => { const definedCount = p.definedServices ? p.definedServices.length : null; const runningCount = runtime.ok ? countRunningServices(p.runtime) : null; @@ -650,9 +703,16 @@ async function runProjects(opts: { runningCount, runtimeOk: runtime.ok, }); + const autostartState = p.autostart?.paused ? "paused" : "always-on"; return [ p.name, p.status, + p.autostart ? autostartState : "", + p.autostart?.lastResult + ? [p.autostart.lastResult.status, p.autostart.lastResult.reason] + .filter(Boolean) + .join(": ") + : "", servicesCell, p.devHost ?? "", p.repoRoot ?? "", @@ -727,7 +787,13 @@ async function outputDaemonProjects({ daemon.json && (!opts.summary || daemon.json.detail_level === "summary") ) { - const { profiling, ...payload } = daemon.json; + const { profiling, ...cached } = daemon.json; + // Policy changes do not wait for Docker's next cache refresh. + const registry = await readProjectsRegistry(); + const payload = overlayAutostartPolicy({ + cached, + projects: registry.projects, + }); writeProfiledProjects({ payload, profiler, @@ -741,6 +807,33 @@ async function outputDaemonProjects({ return false; } +/** Machine intent is fresh even when runtime rows came from an older daemon snapshot. */ +function overlayAutostartPolicy(opts: { + readonly cached: Readonly>; + readonly projects: readonly RegisteredProject[]; +}): Record { + const markers = new Map( + opts.projects.map((project) => [project.id, project.autostart]) + ); + return { + ...opts.cached, + projects: Array.isArray(opts.cached.projects) + ? opts.cached.projects.map((project: unknown) => + isRecord(project) + ? { + ...project, + autostart: serializeProjectAutostart( + typeof project.project_id === "string" + ? markers.get(project.project_id) + : undefined + ), + } + : project + ) + : opts.cached.projects, + }; +} + type RuntimeRecoveryMeta = { readonly resetCount: number; readonly lastResetSummary: string | null; @@ -1521,6 +1614,7 @@ function readRepairOutcomeField(opts: { } export const __testOnlyProjectsCommand = { + overlayAutostartPolicy, buildRuntimeRecoveryNotice, runNativeStatus, filterPruneRegistryProjects, diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts new file mode 100644 index 000000000..f9aa204f7 --- /dev/null +++ b/src/daemon/autostart.ts @@ -0,0 +1,571 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +import { + GLOBAL_CADDY_COMPOSE_FILENAME, + GLOBAL_CADDY_DIR_NAME, +} from "../constants.ts"; +import { childEnvironment } from "../lib/child-environment.ts"; +import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; +import { resolveGlobalHackDir } from "../lib/config-paths.ts"; +import { isRecord } from "../lib/guards.ts"; +import { findProjectContextAtRoot, readProjectConfig } from "../lib/project.ts"; +import { + claimProjectAutostart, + type RegisteredProject, + readProjectsRegistry, + recordProjectAutostartResult, +} from "../lib/projects-registry.ts"; + +export type AutostartResult = { + readonly status: "started" | "skipped" | "failed" | "already_running"; + readonly reason: string | null; + readonly at: string; +}; + +type StartResult = Omit; + +/** Side effects are explicit so ordering, bounds and restart recovery can be tested without an engine. */ +export type AutostartDependencies = { + readonly bootId: () => Promise; + readonly startupRefusal?: () => "native_unsupported" | null; + readonly now: () => number; + readonly sleep: (ms: number, signal?: AbortSignal) => Promise; + readonly engineReady: () => Promise; + readonly routingInstalled: () => Promise; + readonly startRouting: (signal?: AbortSignal) => Promise; + readonly inspect: ( + project: RegisteredProject, + signal?: AbortSignal + ) => Promise<"running" | "stopped" | "unknown">; + readonly start: ( + project: RegisteredProject, + signal?: AbortSignal + ) => Promise; +}; + +const MAC_BOOT_TIME_PATTERN = /sec = (\d+), usec = (\d+)/; + +const ENGINE_WAIT_MS = 60_000; +const ENGINE_POLL_MS = 2000; +const START_STAGGER_MS = 500; +const MAX_CONCURRENT_STARTS = 2; + +/** + * One replay per OS boot, with a durable claim before each launch. An interrupted + * claim is deliberately not retried in this first slice: unknown launch outcome + * is not proof of absence. Explicit `hack up` remains the recovery path. + */ +export async function runAutostartAtLogin( + opts: { + readonly signal?: AbortSignal; + readonly registryPath?: string; + readonly dependencies?: AutostartDependencies; + } = {} +): Promise { + const deps = opts.dependencies ?? productionDependencies(); + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + }); + const candidates = registry.projects + .filter((project) => project.autostart && !project.autostart.paused) + .sort( + (a, b) => + a.name.localeCompare(b.name, "en") || a.id.localeCompare(b.id, "en") + ); + if (candidates.length === 0) { + return; + } + const machineBootId = await deps.bootId().catch(() => null); + const bootId = machineBootId ?? "unavailable"; + const pending = candidates.filter( + (project) => project.autostart?.lastAttemptBootId !== bootId + ); + if (pending.length === 0) { + return; + } + const refusal = await prepareAutostartRuntime({ + deps, + machineBootId, + signal: opts.signal, + }); + if (opts.signal?.aborted) { + return; + } + + const active = new Set>(); + const tasks: Promise[] = []; + let lastStartAt: number | null = null; + try { + for (const candidate of pending) { + if (opts.signal?.aborted) { + break; + } + while (active.size >= MAX_CONCURRENT_STARTS) { + await Promise.race(active); + } + if (lastStartAt !== null) { + const delay = START_STAGGER_MS - (deps.now() - lastStartAt); + if (delay > 0) { + await deps.sleep(delay, opts.signal); + } + } + if (opts.signal?.aborted) { + break; + } + const project = await claimProjectAutostart({ + id: candidate.id, + bootId, + registryPath: opts.registryPath, + }); + if (!project) { + continue; + } + let dispatched = () => { + /* Replaced synchronously by the Promise executor. */ + }; + const dispatch = new Promise((done) => { + dispatched = done; + }); + const task = startClaimedProject({ + project, + bootId, + refusal, + deps, + onDispatched: dispatched, + ...opts, + }); + const tracked = task.then( + () => { + active.delete(tracked); + }, + () => { + active.delete(tracked); + } + ); + active.add(tracked); + tasks.push(task); + await dispatch; + lastStartAt = deps.now(); + } + } finally { + await Promise.all(tasks); + } +} + +async function prepareAutostartRuntime(opts: { + readonly deps: AutostartDependencies; + readonly machineBootId: string | null; + readonly signal?: AbortSignal; +}): Promise { + const refusal = opts.deps.startupRefusal?.(); + if (refusal) { + return refusal; + } + if (!opts.machineBootId) { + return "boot_identity_unavailable"; + } + const { deps, signal } = opts; + const deadline = deps.now() + ENGINE_WAIT_MS; + let ready = false; + while (!signal?.aborted) { + ready = await deps.engineReady().catch(() => false); + if (ready || deps.now() >= deadline) { + break; + } + await deps.sleep(Math.min(ENGINE_POLL_MS, deadline - deps.now()), signal); + } + if (!ready) { + return "engine_timeout"; + } + if ( + (await deps.routingInstalled()) && + !(await deps.startRouting(signal).catch(() => false)) + ) { + return "routing_failed"; + } + return null; +} + +async function startClaimedProject(opts: { + readonly project: RegisteredProject; + readonly bootId: string; + readonly refusal: string | null; + readonly onDispatched: () => void; + readonly deps: AutostartDependencies; + readonly signal?: AbortSignal; + readonly registryPath?: string; +}): Promise { + let result: StartResult = { status: "skipped", reason: opts.refusal }; + try { + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + }); + const current = registry.projects.find( + (project) => project.id === opts.project.id + ); + if ( + !current?.autostart || + current.autostart.paused || + opts.signal?.aborted + ) { + result = { + status: "skipped", + reason: opts.signal?.aborted ? "daemon_stopped" : "paused", + }; + } else if (!opts.refusal) { + const state = await opts.deps.inspect(current, opts.signal); + if (state === "running") { + result = { status: "already_running", reason: null }; + } else if (state === "unknown") { + result = { status: "skipped", reason: "runtime_unknown" }; + } else { + result = await dispatchIfStillWanted({ ...opts, project: current }); + } + } + } catch { + result = { status: "failed", reason: "start_failed" }; + } finally { + opts.onDispatched(); + } + await recordProjectAutostartResult({ + id: opts.project.id, + bootId: opts.bootId, + result: { ...result, at: new Date(opts.deps.now()).toISOString() }, + registryPath: opts.registryPath, + }); +} + +async function dispatchIfStillWanted(opts: { + readonly project: RegisteredProject; + readonly onDispatched: () => void; + readonly deps: AutostartDependencies; + readonly signal?: AbortSignal; + readonly registryPath?: string; +}): Promise { + const latest = ( + await readProjectsRegistry({ registryPath: opts.registryPath }) + ).projects.find((project) => project.id === opts.project.id); + if (!latest?.autostart || latest.autostart.paused || opts.signal?.aborted) { + return { + status: "skipped", + reason: opts.signal?.aborted ? "daemon_stopped" : "paused", + }; + } + if (latest.autostart.branch !== opts.project.autostart?.branch) { + return { status: "skipped", reason: "target_changed" }; + } + const start = opts.deps.start(latest, opts.signal); + opts.onDispatched(); + return await start; +} + +/** Only closed reason codes cross into the registry; subprocess output is never retained. */ +export function classifyAutostartExit(opts: { + readonly exitCode: number; + readonly stdout: string; + readonly reason?: "output_limit" | "daemon_stopped" | "start_timeout"; +}): StartResult { + if (opts.reason) { + return { + status: opts.reason === "daemon_stopped" ? "skipped" : "failed", + reason: opts.reason, + }; + } + if (opts.exitCode === 0) { + return { status: "started", reason: null }; + } + let code: unknown; + let detail: unknown; + try { + const value: unknown = JSON.parse(opts.stdout); + if (isRecord(value) && isRecord(value.error)) { + code = value.error.code; + detail = value.error.detail; + } + } catch { + /* A malformed response is a failure, never a reason to retain output. */ + } + if ( + code === "E_CONFIG_INVALID" && + isRecord(detail) && + (detail.reason === "pending_env_migration" || + detail.reason === "keychain_unavailable") + ) { + return { status: "skipped", reason: detail.reason }; + } + if (code === "E_PROJECT_NOT_FOUND") { + return { status: "skipped", reason: "project_unavailable" }; + } + const skipReasons: Readonly> = { + E_INTERACTIVE_REQUIRED: "interactive_required", + E_ENV_KEY_MISSING: "env_key_missing", + }; + const reason = typeof code === "string" ? skipReasons[code] : undefined; + return reason + ? { status: "skipped", reason } + : { + status: "failed", + reason: opts.exitCode === 124 ? "start_timeout" : "start_failed", + }; +} + +/** Native startup waits for separate runtime/login qualification; never probe Docker for it. */ +export function autostartRuntimeRefusal( + env: Readonly> = process.env +): "native_unsupported" | null { + try { + return resolveNativeRuntimeSelection(env) ? "native_unsupported" : null; + } catch { + return "native_unsupported"; + } +} + +function productionDependencies(): AutostartDependencies { + const invocation = [ + process.execPath, + ...(Bun.main.startsWith("/$bunfs/") + ? [] + : [fileURLToPath(new URL("../../index.ts", import.meta.url))]), + ]; + return { + startupRefusal: autostartRuntimeRefusal, + now: Date.now, + sleep: async (ms, signal) => { + if (signal?.aborted) { + return; + } + await new Promise((done) => { + const finish = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", finish); + done(); + }; + const timer = setTimeout(finish, ms); + signal?.addEventListener("abort", finish, { once: true }); + }); + }, + bootId: readMachineBootId, + engineReady: async () => + ( + await runAutostartSubprocess( + ["docker", "info", "--format", "{{.ID}}"], + { + timeoutMs: 2000, + } + ) + ).exitCode === 0, + routingInstalled: async () => + Bun.file( + resolve( + resolveGlobalHackDir(), + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CADDY_COMPOSE_FILENAME + ) + ).exists(), + startRouting: async (signal) => + ( + await runAutostartSubprocess( + [...invocation, "global", "up", "--no-interactive"], + { + timeoutMs: 60_000, + signal, + } + ) + ).exitCode === 0, + inspect: inspectComposeProject, + start: async (project, signal) => { + const args = [ + ...invocation, + "up", + "-d", + "--no-interactive", + "--json", + "--path", + project.repoRoot, + ]; + if (project.autostart?.branch) { + args.push(`--branch=${project.autostart.branch}`); + } + return classifyAutostartExit( + await runAutostartSubprocess(args, { timeoutMs: 120_000, signal }) + ); + }, + }; +} + +async function inspectComposeProject( + project: RegisteredProject, + signal?: AbortSignal +): Promise<"running" | "stopped" | "unknown"> { + const context = await findProjectContextAtRoot({ + projectRoot: project.repoRoot, + projectDirName: project.projectDirName, + }); + if (!context) { + return "unknown"; + } + const cfg = await readProjectConfig(context); + if (cfg.parseError) { + return "unknown"; + } + const name = await resolveComposeProjectName({ project: context, cfg }); + const composeName = project.autostart?.branch + ? `${name}--${project.autostart.branch}` + : name; + const result = await runAutostartSubprocess( + [ + "docker", + "ps", + "-a", + "--filter", + `label=com.docker.compose.project=${composeName}`, + "--filter", + "label=com.docker.compose.oneoff=False", + "--format", + "{{.ID}}", + ], + { timeoutMs: 5000, signal } + ); + if (result.exitCode !== 0) { + return "unknown"; + } + const ids = result.stdout.trim().split("\n").filter(Boolean); + if (ids.length === 0) { + return "stopped"; + } + const inspected = await runAutostartSubprocess( + [ + "docker", + "inspect", + "--format", + "{{.State.Status}}|{{.State.ExitCode}}", + ...ids, + ], + { timeoutMs: 5000, signal } + ); + if (inspected.exitCode !== 0) { + return "unknown"; + } + return classifyAutostartContainers(inspected.stdout); +} + +/** Completed successful one-shot services are adopted alongside running services. */ +export function classifyAutostartContainers( + text: string +): "running" | "stopped" | "unknown" { + const states = text.trim().split("\n").filter(Boolean); + if (states.length === 0) { + return "unknown"; + } + let running = false; + for (const state of states) { + const [status, exitCode, extra] = state.split("|"); + if (extra !== undefined || !status || !exitCode) { + return "unknown"; + } + if (status !== "running" && !(status === "exited" && exitCode === "0")) { + return "stopped"; + } + running ||= status === "running"; + } + return running ? "running" : "stopped"; +} + +async function readMachineBootId(): Promise { + if (process.platform === "linux") { + try { + return ( + (await Bun.file("/proc/sys/kernel/random/boot_id").text()).trim() || + null + ); + } catch { + return null; + } + } + if (process.platform === "darwin") { + const result = await runAutostartSubprocess( + ["sysctl", "-n", "kern.boottime"], + { + timeoutMs: 2000, + } + ); + const match = result.stdout.match(MAC_BOOT_TIME_PATTERN); + return result.exitCode === 0 && match ? `${match[1]}:${match[2]}` : null; + } + return null; +} + +/** Own process group, no TTY, no keychain, no recursive daemon startup, bounded output and lifetime. */ +export async function runAutostartSubprocess( + argv: readonly string[], + opts: { + readonly timeoutMs: number; + readonly signal?: AbortSignal; + readonly env?: Readonly>; + } +): Promise<{ + exitCode: number; + stdout: string; + reason?: "output_limit" | "daemon_stopped" | "start_timeout"; +}> { + if (opts.signal?.aborted) { + return { exitCode: 124, stdout: "", reason: "daemon_stopped" }; + } + const proc = Bun.spawn([...argv], { + env: { + ...childEnvironment(opts.env ?? process.env, { + HACK_NO_INTERACTIVE: "1", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + }), + HACK_AUTOSTART_LOGIN: "1", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + detached: true, + }); + let reason: "output_limit" | "daemon_stopped" | "start_timeout" | undefined; + const stop = ( + failure: "output_limit" | "daemon_stopped" | "start_timeout" + ) => { + reason ??= failure; + try { + process.kill(-proc.pid, "SIGKILL"); + } catch { + /* Own child may already have exited. */ + } + }; + const timer = setTimeout(() => stop("start_timeout"), opts.timeoutMs); + const abort = () => stop("daemon_stopped"); + opts.signal?.addEventListener("abort", abort, { once: true }); + let stdout = ""; + const decoder = new TextDecoder(); + const reader = proc.stdout.getReader(); + let outputBytes = 0; + try { + while (true) { + const { value: chunk, done } = await reader.read(); + if (done) { + break; + } + outputBytes += chunk.byteLength; + if (outputBytes > 64 * 1024) { + stop("output_limit"); + break; + } + stdout += decoder.decode(chunk, { stream: true }); + } + const exitCode = await proc.exited; + return { + exitCode: reason ? 124 : exitCode, + stdout: reason ? "" : stdout, + ...(reason ? { reason } : {}), + }; + } finally { + reader.releaseLock(); + clearTimeout(timer); + opts.signal?.removeEventListener("abort", abort); + } +} diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 1504efdf0..ab6895c28 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -20,6 +20,7 @@ import { createShellService } from "../control-plane/extensions/supervisor/shell import type { ControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { ensureDir } from "../lib/fs.ts"; import { resolveRegisteredProjectById } from "../lib/projects-registry.ts"; +import { runAutostartAtLogin } from "./autostart.ts"; import { evaluateGatewayRouteAccess, validateControlPlaneRouteTarget, @@ -337,8 +338,12 @@ export async function runDaemon({ } } + const autostartAbort = new AbortController(); + let autostartWork: Promise = Promise.resolve(); const shutdown = async ({ reason }: { readonly reason: string }) => { logger.warn({ message: `Shutting down hackd (${reason})` }); + autostartAbort.abort(); + await autostartWork; refreshScheduler.stop(); watcher.stop(); await daemonProxy?.close(); @@ -359,6 +364,16 @@ export async function runDaemon({ logger.info({ message: `hackd started (pid ${process.pid}, version ${packageJson.version})`, }); + if (process.env.HACK_DAEMON_AUTOSTART_DISABLE !== "1") { + autostartWork = runAutostartAtLogin({ + signal: autostartAbort.signal, + }).catch(() => { + logger.warn({ + message: + "Autostart could not complete; inspect hack autostart list and run hack up manually. Values omitted.", + }); + }); + } } async function handleRequest({ diff --git a/src/lib/child-environment.ts b/src/lib/child-environment.ts index 6db8e26ed..bc454b156 100644 --- a/src/lib/child-environment.ts +++ b/src/lib/child-environment.ts @@ -19,7 +19,11 @@ export function childEnvironment( ): Record { const env: Record = Object.create(null); for (const [key, value] of Object.entries(base)) { - if (typeof value === "string" && !unsetKeys.includes(key)) { + if ( + typeof value === "string" && + !unsetKeys.includes(key) && + key !== "HACK_AUTOSTART_LOGIN" + ) { env[key] = value; } } @@ -41,6 +45,9 @@ export function hackReexecEnvironment( base: Readonly> = process.env ): Record { const env = childEnvironment(base); + if (base.HACK_AUTOSTART_LOGIN === "1") { + env.HACK_AUTOSTART_LOGIN = "1"; + } for (const key of HACK_KEY_MATERIAL_VARIABLES) { const value = base[key]; if (typeof value === "string") { diff --git a/src/lib/keychain.ts b/src/lib/keychain.ts index 02bf033a7..d383864f0 100644 --- a/src/lib/keychain.ts +++ b/src/lib/keychain.ts @@ -1,3 +1,4 @@ +import { HackCliError } from "./cli-result.ts"; import { nativeKeychain } from "./native-keychain.ts"; /** The single boundary for native keychain access, injectable for isolated callers. */ @@ -17,6 +18,13 @@ export function isKeychainDisabled(): boolean { function assertKeychainEnabled(): void { if (isKeychainDisabled()) { + if (process.env.HACK_AUTOSTART_LOGIN === "1") { + throw new HackCliError({ + code: "E_ENV_KEY_MISSING", + message: + "Autostart skipped: required keychain access is unavailable without interaction.", + }); + } throw new Error( "Keychain access is disabled by HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK." ); diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 2c7515aeb..e17e781d0 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -417,10 +417,15 @@ const STATUS_NOTES: Readonly< async function showStatus(options: { readonly result: NativeAuthoredProjectStatus; readonly json?: boolean; + readonly statusMetadata?: NativeComposeCommandOptions["statusMetadata"]; }): Promise { const { result } = options; if (options.json) { - emitCliResult({ result: okResult({ data: result }) }); + emitCliResult({ + result: okResult({ + data: { ...result, ...(await options.statusMetadata?.()) }, + }), + }); return; } if (result.status !== "observed") { @@ -435,6 +440,16 @@ async function showStatus(options: { item.health ?? "", ]), }); + const policy = (await options.statusMetadata?.())?.autostart; + if (policy) { + await display.kv({ + entries: [ + ["Autostart", policy.paused ? "paused" : "always-on"], + ["Last autostart", policy.last_result?.status ?? "not attempted"], + ["Autostart reason", policy.last_result?.reason ?? ""], + ], + }); + } } function commandFailure(options: { @@ -526,7 +541,11 @@ async function runStatus(input: LifecycleInput): Promise { if (input.signal.aborted) { return 130; } - await showStatus({ result, json: input.options.json }); + await showStatus({ + result, + json: input.options.json, + statusMetadata: input.options.statusMetadata, + }); return 0; } diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index 55a00e331..ba56fb10f 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -273,7 +273,7 @@ async function observe(opts: { })); process.stdout.write( options.json - ? `${JSON.stringify({ owner: "legacy-compose", services: report })}\n` + ? `${JSON.stringify({ owner: "legacy-compose", services: report, ...(await options.statusMetadata?.()) })}\n` : report.map((entry) => `${entry.service}\t${entry.status}\n`).join("") ); return 0; diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index a02db2113..954500cc7 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -152,10 +152,15 @@ import { } from "./native-compose-selection.ts"; import { NativeConfigCompilerError } from "./native-config-compiler.ts"; import { nativeFilePlanningRequired } from "./native-file-plan-protocol.ts"; +import type { serializeProjectAutostart } from "./project-views.ts"; import { run } from "./shell.ts"; type Operation = "up" | "restart" | "down" | "ps" | "logs" | "exec" | "run"; export type NativeComposeCommandOptions = { + /** Add machine policy only for status callers; ordinary ps keeps its existing shape. */ + readonly statusMetadata?: () => Promise<{ + readonly autostart: ReturnType; + }>; readonly cwd: string; readonly path?: string; readonly project?: string; @@ -736,6 +741,7 @@ async function savedCommand(opts: { beforeHooksPending: true, hostHookPhase: state.hostHookPhase, services: [], + ...(await options.statusMetadata?.()), }, }), }); @@ -1029,6 +1035,7 @@ async function savedCommand(opts: { oneoff, }) ), + ...(await options.statusMetadata?.()), }, }), }); diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index 40d95b80d..c824e405c 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -84,6 +84,7 @@ export type ProjectLifecycleView = { export type ProjectView = { readonly projectId?: string; + readonly autostart?: RegisteredProject["autostart"]; readonly name: string; readonly devHost: string | null; readonly repoRoot: string | null; @@ -312,6 +313,7 @@ async function buildRegisteredProjectView(opts: { return { projectId: opts.registration.id, + autostart: opts.registration.autostart, name: opts.name, devHost: opts.registration.devHost ?? null, repoRoot: opts.registration.repoRoot, @@ -340,6 +342,7 @@ function buildUnavailableProjectView(opts: { }): ProjectView { return { projectId: opts.registration.id, + autostart: opts.registration.autostart, name: opts.name, devHost: opts.registration.devHost ?? null, repoRoot: opts.registration.repoRoot, @@ -394,6 +397,31 @@ function buildUnregisteredProjectView(opts: { }; } +/** Stable desktop-facing machine policy; per-boot admission bookkeeping remains internal. */ +export function serializeProjectAutostart( + marker: RegisteredProject["autostart"] +): { + readonly branch: string | null; + readonly paused: boolean; + readonly last_result: NonNullable< + RegisteredProject["autostart"] + >["lastResult"]; +} | null { + return marker + ? { + branch: marker.branch, + paused: marker.paused, + last_result: marker.lastResult + ? { + status: marker.lastResult.status, + reason: marker.lastResult.reason, + at: marker.lastResult.at, + } + : null, + } + : null; +} + /** Summary projection deliberately excludes container labels, mounts and command definitions. */ export function serializeProjectSummary( view: ProjectView @@ -407,6 +435,7 @@ export function serializeProjectSummary( ); return { project_id: view.projectId ?? null, + autostart: serializeProjectAutostart(view.autostart), name: view.name, repo_root: view.repoRoot, dev_host: view.devHost, @@ -435,6 +464,7 @@ export function serializeProjectView( ): Record { return { project_id: view.projectId ?? null, + autostart: serializeProjectAutostart(view.autostart), name: view.name, dev_host: view.devHost ?? null, repo_root: view.repoRoot ?? null, diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 90e416007..4c17236c0 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -46,6 +46,28 @@ export interface RegisteredProjectWorktree { readonly lastSeenAt: string; } +/** Value-free result of the daemon's most recent startup attempt. */ +export interface ProjectAutostartResult { + readonly status: "started" | "skipped" | "failed" | "already_running"; + readonly reason: string | null; + readonly at: string; +} + +/** Machine-owned intent; repository configuration never creates this marker. */ +export interface ProjectAutostart { + readonly branch: string | null; + readonly paused: boolean; + readonly lastResult: ProjectAutostartResult | null; + /** Persisted before starting: daemon replacement in the same boot cannot replay it. */ + readonly lastAttemptBootId: string | null; + /** Invalid/future intent is visible but never eligible for automatic starts. */ + readonly invalid?: true; +} + +// Invalid markers remain repairable and survive unrelated registry writes byte-for-JSON-value. +// The weak key follows the marker through project spreads without exposing raw data in views. +const invalidAutostartValues = new WeakMap(); + export interface RegisteredProject { readonly id: string; readonly name: string; @@ -57,6 +79,7 @@ export interface RegisteredProject { readonly lastSeenAt?: string; /** Linked git worktree checkouts of this project seen by the CLI (additive; absent in older registries). */ readonly worktrees?: readonly RegisteredProjectWorktree[]; + readonly autostart?: ProjectAutostart; } export interface ProjectsRegistry { @@ -187,6 +210,8 @@ export async function readProjectsRegistryStrict(opts?: { readonly registry: ProjectsRegistry; /** Project entries the registry holds that are not valid (skipped). */ readonly rejected: number; + /** Invalid/future autostart markers retained as disabled diagnostics. */ + readonly invalidAutostart: number; } > { const text = await readRegistryText(opts?.registryPath ?? getRegistryPath()); @@ -224,7 +249,14 @@ export async function readProjectsRegistryStrict(opts?: { rejected += 1; } } - return { kind: "valid", registry, rejected }; + return { + kind: "valid", + registry, + rejected, + invalidAutostart: registry.projects.filter( + (project) => project.autostart?.invalid + ).length, + }; } type RegistrationUpsertOptions = { @@ -602,6 +634,216 @@ export async function resolveRegisteredProjectById(opts: { }; } +type AutostartRegistryOptions = { + readonly registryPath?: string; + readonly signal?: AbortSignal; +}; + +/** Explicit machine opt-in. Re-adding an unchanged target preserves its boot claim. */ +export async function setProjectAutostart( + opts: AutostartRegistryOptions & { + readonly id: string; + readonly branch?: string | null; + } +): Promise { + let selected: RegisteredProject | undefined; + await mutateProjectAutostart({ + ...opts, + update: (project) => { + const branch = opts.branch ?? null; + const old = project.autostart; + if (old?.invalid) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Autostart state is invalid or newer than this CLI. Remove the marker with 'hack autostart remove' before adding it again. Values omitted.", + }); + } + const autostart: ProjectAutostart = { + ...old, + branch, + paused: false, + lastResult: old?.lastResult ?? null, + lastAttemptBootId: old?.lastAttemptBootId ?? null, + }; + selected = { ...project, autostart }; + return selected; + }, + }); + if (!selected) { + throw new HackCliError({ + code: "E_PROJECT_NOT_FOUND", + message: + "Project is not registered; register it before enabling autostart.", + }); + } + return selected; +} + +/** Remove machine intent without stopping the project's running processes. */ +export async function removeProjectAutostart( + opts: AutostartRegistryOptions & { readonly id: string } +): Promise { + const changed = await mutateProjectAutostart({ + ...opts, + update: (project) => { + const { autostart: _removed, ...rest } = project; + return rest; + }, + }); + return changed[0] ?? null; +} + +/** Pause/resume only existing opt-ins. An omitted id selects all marked projects. */ +export async function setProjectAutostartPaused( + opts: AutostartRegistryOptions & { + readonly id?: string; + readonly paused: boolean; + } +): Promise { + return await mutateProjectAutostart({ + ...opts, + update: (project) => + project.autostart && !project.autostart.invalid + ? { + ...project, + autostart: { ...project.autostart, paused: opts.paused }, + } + : project, + }); +} + +/** + * Atomically claim an unpaused opt-in once per OS boot, before any child starts. + * A crash after publication can skip that boot's attempt; it never duplicates it. + * The returned registration is the locked reread, not a stale scheduling snapshot. + */ +export async function claimProjectAutostart( + opts: AutostartRegistryOptions & { + readonly id: string; + readonly bootId: string; + } +): Promise { + if (!opts.bootId.trim()) { + throw new Error("Autostart requires a non-empty OS boot identity."); + } + let claimed: RegisteredProject | null = null; + await mutateProjectAutostart({ + ...opts, + update: (project) => { + const marker = project.autostart; + if ( + !marker || + marker.invalid || + marker.paused || + marker.lastAttemptBootId === opts.bootId + ) { + return project; + } + claimed = { + ...project, + autostart: { ...marker, lastAttemptBootId: opts.bootId }, + }; + return claimed; + }, + }); + return claimed; +} + +/** A late result cannot recreate removed intent or overwrite a newer boot claim. */ +export async function recordProjectAutostartResult( + opts: AutostartRegistryOptions & { + readonly id: string; + readonly bootId: string; + readonly result: ProjectAutostartResult; + } +): Promise { + let recorded = false; + await mutateProjectAutostart({ + ...opts, + update: (project) => { + if ( + project.autostart?.invalid || + project.autostart?.lastAttemptBootId !== opts.bootId + ) { + return project; + } + recorded = true; + return { + ...project, + autostart: { ...project.autostart, lastResult: opts.result }, + }; + }, + }); + return recorded; +} + +/** Every intent mutation rereads and publishes under the same registry lock as upsert. */ +async function mutateProjectAutostart( + opts: AutostartRegistryOptions & { + readonly id?: string; + readonly update: (project: RegisteredProject) => RegisteredProject; + } +): Promise { + const registryPath = opts.registryPath ?? getRegistryPath(); + await ensureDir(dirname(registryPath)); + return await withProjectsRegistryLock({ + lockPath: `${registryPath}.lock`, + signal: opts.signal, + run: async () => { + const read = await readProjectsRegistryStrict({ registryPath }); + if ( + read.kind === "invalid" || + (read.kind === "valid" && read.rejected > 0) + ) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Projects registry is invalid; autostart intent was not changed.", + }); + } + const current = + read.kind === "valid" + ? read.registry + : { version: REGISTRY_VERSION, projects: [] }; + if (opts.id !== undefined) { + selectRegisteredProjectById({ + projects: current.projects, + id: opts.id, + }); + } else { + for (const project of current.projects) { + selectRegisteredProjectById({ + projects: current.projects, + id: project.id, + }); + } + } + const selected: RegisteredProject[] = []; + const projects = current.projects.map((project) => { + if ( + opts.id !== undefined ? project.id !== opts.id : !project.autostart + ) { + return project; + } + const updated = opts.update(project); + selected.push(updated); + return updated; + }); + if ( + projects.some((project, index) => project !== current.projects[index]) + ) { + await writeRegistryAtomic( + registryPath, + { ...current, projects }, + opts.signal + ); + } + return selected; + }, + }); +} + /** Validate every requested ID under ownership before removing any entry. */ export async function removeProjectsById(opts: { readonly ids: readonly string[]; @@ -706,6 +948,7 @@ function parseProject(value: unknown): RegisteredProject | null { const devHost = getString(value, "devHost") ?? undefined; const lastSeenAt = getString(value, "lastSeenAt") ?? undefined; const worktrees = parseWorktrees(value.worktrees); + const autostart = parseProjectAutostart(value.autostart); return { id, @@ -718,9 +961,77 @@ function parseProject(value: unknown): RegisteredProject | null { createdAt, ...(lastSeenAt ? { lastSeenAt } : {}), ...(worktrees.length > 0 ? { worktrees } : {}), + ...(autostart ? { autostart } : {}), }; } +/** Unknown intent disables only its automatic start; ordinary project operations still work. */ +function parseProjectAutostart(value: unknown): ProjectAutostart | undefined { + if (value === undefined) { + return undefined; + } + if ( + !( + isRecord(value) && + (value.branch === null || typeof value.branch === "string") + ) || + typeof value.paused !== "boolean" || + !( + value.lastAttemptBootId === null || + typeof value.lastAttemptBootId === "string" + ) + ) { + return invalidProjectAutostart(value); + } + let lastResult: ProjectAutostartResult | null = null; + if (value.lastResult !== null) { + const result = value.lastResult; + if ( + !( + isRecord(result) && + (result.status === "started" || + result.status === "skipped" || + result.status === "failed" || + result.status === "already_running") && + (result.reason === null || typeof result.reason === "string") + ) || + typeof result.at !== "string" + ) { + return invalidProjectAutostart(value); + } + lastResult = { + ...result, + status: result.status, + reason: result.reason, + at: result.at, + }; + } + return { + ...value, + branch: value.branch, + paused: value.paused, + lastAttemptBootId: value.lastAttemptBootId, + lastResult, + }; +} + +function invalidProjectAutostart(raw: unknown): ProjectAutostart { + const marker: ProjectAutostart = { + branch: null, + paused: true, + lastAttemptBootId: null, + lastResult: { + status: "skipped", + reason: "invalid_marker", + // Sentinel: malformed state is observed, not an attempted project start. + at: "1970-01-01T00:00:00.000Z", + }, + invalid: true, + }; + invalidAutostartValues.set(marker, raw); + return marker; +} + function parseWorktrees(value: unknown): readonly RegisteredProjectWorktree[] { if (!Array.isArray(value)) { return []; @@ -768,7 +1079,20 @@ async function writeRegistryAtomic( ): Promise { await writeProjectsRegistryAtomic({ path, - text: `${JSON.stringify(registry, null, 2)}\n`, + text: `${JSON.stringify( + registry, + (_key, value: unknown) => { + if ( + typeof value === "object" && + value !== null && + invalidAutostartValues.has(value) + ) { + return invalidAutostartValues.get(value); + } + return value; + }, + 2 + )}\n`, signal, }); } diff --git a/tests/autostart-cli.test.ts b/tests/autostart-cli.test.ts new file mode 100644 index 000000000..a6b6b0252 --- /dev/null +++ b/tests/autostart-cli.test.ts @@ -0,0 +1,246 @@ +import { afterAll, beforeAll, beforeEach, expect, spyOn, test } from "bun:test"; +import { parsePositionalsForCommand } from "../src/cli/command.ts"; +import { CLI_SPEC } from "../src/cli/spec.ts"; +import { autostartCommand } from "../src/commands/autostart.ts"; +import { __testOnlyProjectsCommand } from "../src/commands/projects.ts"; +import { serializeProjectAutostart } from "../src/lib/project-views.ts"; +import type { RegisteredProject } from "../src/lib/projects-registry.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +const marker = { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: "private-admission-id", +} as const; +const project: RegisteredProject = { + id: "example-id", + name: "example", + repoRoot: "/example/project", + projectDir: "/example/project/.hack", + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + autostart: marker, +}; +const mutations: unknown[] = []; +let reads = 0; +const linkedRoots = new Set(); +const gitMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/git-worktree.ts", + overrides: { + isLinkedGitWorktree: ({ repoRoot }: { readonly repoRoot: string }) => + Promise.resolve(linkedRoots.has(repoRoot)), + }, +}); +const registryMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/projects-registry.ts", + overrides: { + readProjectsRegistry: () => { + reads += 1; + return Promise.resolve({ version: 1, projects: [project] }); + }, + setProjectAutostart: (options: unknown) => { + mutations.push(options); + return Promise.resolve(project); + }, + removeProjectAutostart: (options: unknown) => { + mutations.push(options); + return Promise.resolve(project); + }, + setProjectAutostartPaused: (options: unknown) => { + mutations.push(options); + return Promise.resolve([project]); + }, + }, +}); +beforeAll(() => { + registryMock.activate(); + gitMock.activate(); +}); +afterAll(() => { + gitMock.deactivate(); + registryMock.deactivate(); +}); +beforeEach(() => { + mutations.length = 0; + reads = 0; + linkedRoots.clear(); +}); +const ctx = { cwd: "/example/project/subdirectory", cli: CLI_SPEC }; +const raw = { argv: [], positionals: [] }; + +async function capture(action: () => Promise) { + let output = ""; + const write = spyOn(process.stdout, "write").mockImplementation((value) => { + output += String(value); + return true; + }); + try { + return { code: await action(), output }; + } finally { + write.mockRestore(); + } +} + +test("autostart add selects current registered project and passes branch without reading project configuration", async () => { + const result = await capture(() => + autostartCommand.subcommands[0].handler({ + ctx, + args: { + options: { branch: "preview" }, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + [] + ), + raw, + }, + }) + ); + expect(result.code).toBe(0); + expect(mutations).toEqual([{ id: "example-id", branch: "preview" }]); + expect(result.output).toContain("hack daemon install"); +}); + +test("pause and resume without a project select every marker", async () => { + await capture(() => + autostartCommand.subcommands[3].handler({ + ctx, + args: { + options: {}, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + [] + ), + raw, + }, + }) + ); + await capture(() => + autostartCommand.subcommands[4].handler({ + ctx, + args: { + options: {}, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + [] + ), + raw, + }, + }) + ); + expect(mutations).toEqual([ + { id: undefined, paused: true }, + { id: undefined, paused: false }, + ]); + expect(reads).toBe(0); +}); + +test("malformed project selector refuses before registry access or mutation", async () => { + await expect( + autostartCommand.subcommands[1].handler({ + ctx, + args: { + options: {}, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[1].positionals, + ["KEY=value"] + ), + raw, + }, + }) + ).rejects.toThrow("Invalid --project value"); + expect(reads).toBe(0); + expect(mutations).toEqual([]); +}); + +test("autostart list JSON exposes stable policy and result fields without admission bookkeeping", async () => { + const result = await capture(() => + autostartCommand.subcommands[2].handler({ + ctx, + args: { options: { json: true }, positionals: {}, raw }, + }) + ); + expect(JSON.parse(result.output)).toEqual({ + projects: [ + { + project_id: "example-id", + name: "example", + autostart: { branch: null, paused: false, last_result: null }, + }, + ], + }); + expect(result.output).not.toContain("private-admission-id"); + expect(serializeProjectAutostart(undefined)).toBeNull(); + expect( + serializeProjectAutostart({ + ...marker, + paused: true, + lastResult: { + status: "skipped", + reason: "interactive_required", + at: "2026-01-01T00:00:00Z", + }, + }) + ).toEqual({ + branch: null, + paused: true, + last_result: { + status: "skipped", + reason: "interactive_required", + at: "2026-01-01T00:00:00Z", + }, + }); +}); + +test("fresh registry policy overrides cached daemon results and removed markers", () => { + const cached = { + projects: [ + { + project_id: "example-id", + name: "example", + autostart: { paused: false }, + }, + { project_id: "removed-id", autostart: { paused: false } }, + ], + runtime_ok: true, + }; + expect( + __testOnlyProjectsCommand.overlayAutostartPolicy({ + cached, + projects: [{ ...project, autostart: { ...marker, paused: true } }], + }) + ).toEqual({ + projects: [ + { + project_id: "example-id", + name: "example", + autostart: { branch: null, paused: true, last_result: null }, + }, + { project_id: "removed-id", autostart: null }, + ], + runtime_ok: true, + }); + expect(cached.projects[0]?.autostart.paused).toBe(false); +}); + +for (const linkedRoot of [ctx.cwd, project.repoRoot]) { + test(`autostart add refuses linked checkout admission at ${linkedRoot === ctx.cwd ? "cwd" : "registered root"}`, async () => { + linkedRoots.add(linkedRoot); + await expect( + autostartCommand.subcommands[0].handler({ + ctx, + args: { + options: { branch: "preview" }, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + ["example"] + ), + raw, + }, + }) + ).rejects.toThrow("Autostart currently requires the primary checkout"); + expect(mutations).toEqual([]); + }); +} diff --git a/tests/autostart-keychain.test.ts b/tests/autostart-keychain.test.ts new file mode 100644 index 000000000..91dc036e6 --- /dev/null +++ b/tests/autostart-keychain.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { buildLifecycleJsonErrorResult } from "../src/commands/project.ts"; +import { classifyAutostartExit } from "../src/daemon/autostart.ts"; +import { createKeychainBackend } from "../src/lib/keychain.ts"; +import { ProjectEnvKeyMissingError } from "../src/lib/project-env-config.ts"; +import { SecretStoreKeyUnavailableError } from "../src/lib/secret-store.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +test("real key refusals become a closed skipped result without invoking keychain", async () => { + const previousLogin = process.env.HACK_AUTOSTART_LOGIN; + const previousDisable = process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK; + try { + process.env.HACK_AUTOSTART_LOGIN = "1"; + process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK = "1"; + let called = false; + const backend = createKeychainBackend({ + backend: { + get: async () => { + called = true; + return null; + }, + set: async () => { + called = true; + }, + delete: async () => { + called = true; + return false; + }, + }, + }); + let keychainError: unknown; + try { + await backend.get({ service: "fixture", name: "SAMPLE_KEY" }); + } catch (error) { + keychainError = error; + } + expect(keychainError).toBeInstanceOf(Error); + for (const error of [ + keychainError, + new ProjectEnvKeyMissingError("fixture"), + new SecretStoreKeyUnavailableError("fixture"), + ]) { + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify(buildLifecycleJsonErrorResult({ error })), + }) + ).toEqual({ status: "skipped", reason: "env_key_missing" }); + } + expect(called).toBe(false); + } finally { + restoreEnv("HACK_AUTOSTART_LOGIN", previousLogin); + restoreEnv("HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK", previousDisable); + } +}); diff --git a/tests/autostart-lifecycle-intent.test.ts b/tests/autostart-lifecycle-intent.test.ts new file mode 100644 index 000000000..62d3ed37d --- /dev/null +++ b/tests/autostart-lifecycle-intent.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { classifyAutostartExit } from "../src/daemon/autostart.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +let root = ""; +const cli = resolve("index.ts"); +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "autostart-intent-")); + await mkdir(join(root, "project", ".hack"), { recursive: true }); + await mkdir(join(root, "home", ".hack"), { recursive: true }); + await mkdir(join(root, "bin")); + await writeFile( + join(root, "project", ".hack", "hack.config.json"), + '{"name":"sample"}' + ); + await writeFile( + join(root, "project", ".hack", "docker-compose.yml"), + "services:\n app:\n image: example.invalid/app\n" + ); + await writeRegistry(true); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); +async function writeRegistry(paused: boolean) { + await writeFile( + join(root, "home", ".hack", "projects.json"), + JSON.stringify({ + version: 1, + projects: [ + { + id: "sample", + name: "sample", + repoRoot: join(root, "project"), + projectDir: join(root, "project", ".hack"), + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00.000Z", + autostart: { + branch: null, + paused, + lastResult: null, + lastAttemptBootId: null, + }, + }, + ], + }) + ); +} +async function marker() { + const registry = await Bun.file( + join(root, "home", ".hack", "projects.json") + ).json(); + return registry.projects[0].autostart; +} +async function run(args: string[], login = false, cwd = join(root, "project")) { + const child = Bun.spawn( + [process.execPath, cli, "--no-interactive", ...args], + { + cwd, + env: privateCliEnv({ + root, + home: join(root, "home"), + env: { + PATH: join(root, "bin"), + HACK_AUTOSTART_LOGIN: login ? "1" : "0", + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exitCode }; +} +test("down persists pause before a failing runtime, and a later up clears it", async () => { + await writeRegistry(false); + await run(["down", "--json"]); + expect((await marker()).paused).toBe(true); + await run(["up", "--json", "-d"]); + expect((await marker()).paused).toBe(false); +}); +test("an unrelated branch does not pause the marked base instance", async () => { + await writeRegistry(false); + await run(["down", "--branch", "preview", "--json"]); + expect((await marker()).paused).toBe(false); +}); +test("down through a symlinked checkout pauses the canonical registration", async () => { + await writeRegistry(false); + const alias = join(root, "project-link"); + await symlink(join(root, "project"), alias); + await run(["down", "--path", alias, "--json"]); + expect((await marker()).paused).toBe(true); +}); +test("down accepts legacy alias names when deciding whether to pause", async () => { + await writeRegistry(false); + const registryPath = join(root, "home", ".hack", "projects.json"); + const registry = await Bun.file(registryPath).json(); + registry.projects[0].name = "Sample_App"; + await writeFile(registryPath, JSON.stringify(registry)); + await run(["down", "--project", "SAMPLE_APP", "--json"]); + expect((await marker()).paused).toBe(true); +}); +test("login refuses a pending legacy migration without touching dotenv or clearing pause", async () => { + const envFile = join(root, "project", ".hack", ".env"); + await writeFile(envFile, "SAMPLE_VALUE=fixture\n"); + const result = await run(["up", "--json", "-d"], true); + expect(result.exitCode).not.toBe(0); + expect(result.stdout + result.stderr).toContain("pending env migration"); + expect(await Bun.file(envFile).text()).toBe("SAMPLE_VALUE=fixture\n"); + expect((await marker()).paused).toBe(true); + expect(classifyAutostartExit(result)).toEqual({ + status: "skipped", + reason: "pending_env_migration", + }); +}); + +test("up --always registers and marks the selected primary instance before runtime dispatch", async () => { + await writeFile( + join(root, "home", ".hack", "projects.json"), + '{"version":1,"projects":[]}' + ); + await run(["up", "-d", "--always", "--branch", "preview", "--json"]); + expect(await marker()).toMatchObject({ branch: "preview", paused: false }); +}); + +test("linked worktree opt-in refuses without marking the primary checkout", async () => { + const git = Bun.which("git"); + if (!git) { + throw new Error("git is required for the worktree regression fixture"); + } + await symlink(git, join(root, "bin", "git")); + const env = privateCliEnv({ root, home: join(root, "home") }); + const primary = join(root, "project"); + const worktree = join(root, "linked"); + for (const args of [ + ["init", "--initial-branch=main", primary], + ["-C", primary, "add", ".hack"], + [ + "-C", + primary, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "fixture", + ], + ["-C", primary, "worktree", "add", "-b", "preview", worktree], + ]) { + const child = Bun.spawn([git, ...args], { + env, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const message = await new Response(child.stderr).text(); + expect(await child.exited, message).toBe(0); + } + const before = await marker(); + const up = await run(["up", "-d", "--always", "--json"], false, worktree); + expect(up.exitCode).not.toBe(0); + expect(up.stdout + up.stderr).toContain("primary checkout"); + const add = await run(["autostart", "add", "sample"], false, worktree); + expect(add.exitCode).not.toBe(0); + expect(add.stdout + add.stderr).toContain("primary checkout"); + expect(await marker()).toEqual(before); +}); diff --git a/tests/child-environment.test.ts b/tests/child-environment.test.ts index de0724504..7cdce54e1 100644 --- a/tests/child-environment.test.ts +++ b/tests/child-environment.test.ts @@ -256,3 +256,12 @@ test.skipIf(!compiledCli)( expect(result).toBe(0); } ); + +test("login replay context stops at tool boundaries and survives only deliberate Hack reexec", () => { + expect(childEnvironment({ HACK_AUTOSTART_LOGIN: "1", KEEP: "yes" })).toEqual({ + KEEP: "yes", + }); + expect(hackReexecEnvironment({ HACK_AUTOSTART_LOGIN: "1" })).toEqual({ + HACK_AUTOSTART_LOGIN: "1", + }); +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts new file mode 100644 index 000000000..8a668ce74 --- /dev/null +++ b/tests/daemon-autostart.test.ts @@ -0,0 +1,425 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type AutostartDependencies, + autostartRuntimeRefusal, + classifyAutostartContainers, + classifyAutostartExit, + runAutostartAtLogin, + runAutostartSubprocess, +} from "../src/daemon/autostart.ts"; +import { + readProjectsRegistry, + setProjectAutostartPaused, +} from "../src/lib/projects-registry.ts"; + +let root = ""; +let registryPath = ""; +let clock = 0; +let events: string[]; +let starts: { id: string; at: number }[]; +let deps: AutostartDependencies; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-autostart-sequence-")); + registryPath = join(root, "projects.json"); + clock = 0; + events = []; + starts = []; + deps = { + bootId: async () => "boot-1", + now: () => clock, + sleep: async (ms) => { + clock += ms; + }, + engineReady: async () => { + events.push("engine"); + return true; + }, + routingInstalled: async () => true, + startRouting: async () => { + events.push("routing"); + return true; + }, + inspect: async () => "stopped", + start: async (project) => { + starts.push({ id: project.id, at: clock }); + events.push(project.id); + return { status: "started", reason: null }; + }, + }; +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function seed( + names: readonly string[] = ["charlie", "alpha", "bravo"] +): Promise { + await Bun.write( + registryPath, + JSON.stringify({ + version: 1, + projects: names.map((name) => ({ + id: name, + name, + repoRoot: join(root, name), + projectDir: join(root, name, ".hack"), + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00.000Z", + autostart: { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: null, + }, + })), + }) + ); +} + +async function run(signal?: AbortSignal): Promise { + await runAutostartAtLogin({ registryPath, dependencies: deps, signal }); +} + +async function results() { + return (await readProjectsRegistry({ registryPath })).projects.map( + (project) => ({ id: project.id, result: project.autostart?.lastResult }) + ); +} + +test("login waits for engine, restores installed routing, then starts in stable staggered order", async () => { + await seed(); + let probes = 0; + deps = { + ...deps, + engineReady: async () => { + probes++; + return probes === 3; + }, + }; + await run(); + expect(events).toEqual(["routing", "alpha", "bravo", "charlie"]); + expect(starts).toEqual([ + { id: "alpha", at: 4000 }, + { id: "bravo", at: 4500 }, + { id: "charlie", at: 5000 }, + ]); + expect( + (await results()).every((entry) => entry.result?.status === "started") + ).toBe(true); +}); + +test("a daemon restart in the same boot never dispatches twice; a new boot can replay", async () => { + await seed(["alpha"]); + await run(); + await run(); + expect(events).toEqual(["engine", "routing", "alpha"]); + deps = { ...deps, bootId: async () => "boot-2" }; + await run(); + expect(starts.map((start) => start.id)).toEqual(["alpha", "alpha"]); +}); + +test("paused projects and down followed by daemon restart are not started", async () => { + await seed(["alpha", "bravo"]); + await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); + await run(); + expect(starts.map((start) => start.id)).toEqual(["bravo"]); + await setProjectAutostartPaused({ id: "bravo", paused: true, registryPath }); + deps = { ...deps, bootId: async () => "boot-2" }; + await run(); + expect(starts.map((start) => start.id)).toEqual(["bravo"]); +}); + +test("pause while inspecting prevents dispatch", async () => { + await seed(["alpha"]); + deps = { + ...deps, + inspect: async () => { + await setProjectAutostartPaused({ + id: "alpha", + paused: true, + registryPath, + }); + return "stopped"; + }, + }; + await run(); + expect(starts).toEqual([]); +}); + +test("engine wait has a finite bound and records a closed skip reason", async () => { + await seed(["alpha"]); + deps = { ...deps, engineReady: async () => false }; + await run(); + expect(clock).toBe(60_000); + expect(starts).toEqual([]); + expect((await results())[0]?.result).toMatchObject({ + status: "skipped", + reason: "engine_timeout", + }); +}); + +test("routing failure, unknown runtime and unavailable boot identity fail closed", async () => { + await seed(["alpha"]); + deps = { ...deps, startRouting: async () => false }; + await run(); + expect((await results())[0]?.result?.reason).toBe("routing_failed"); + await seed(["alpha"]); + deps = { + ...deps, + routingInstalled: async () => false, + inspect: async () => "unknown", + }; + await run(); + expect((await results())[0]?.result?.reason).toBe("runtime_unknown"); + await seed(["alpha"]); + deps = { ...deps, bootId: async () => null }; + await run(); + expect((await results())[0]?.result?.reason).toBe( + "boot_identity_unavailable" + ); + expect(starts).toEqual([]); +}); + +test("running projects are adopted without running up hooks again", async () => { + await seed(["alpha"]); + deps = { ...deps, inspect: async () => "running" }; + await run(); + expect(starts).toEqual([]); + expect((await results())[0]?.result).toMatchObject({ + status: "already_running", + reason: null, + }); +}); + +test("concurrency is capped at two while launches retain their stagger and stable order", async () => { + await seed(["delta", "charlie", "bravo", "alpha"]); + const releases: (() => void)[] = []; + let active = 0; + let peak = 0; + deps = { + ...deps, + start: async (project) => { + active++; + peak = Math.max(peak, active); + starts.push({ id: project.id, at: clock }); + await new Promise((resolve) => releases.push(resolve)); + active--; + return { status: "started", reason: null }; + }, + }; + const running = run(); + while (releases.length < 2) { + await Bun.sleep(1); + } + expect(starts.map((start) => start.id)).toEqual(["alpha", "bravo"]); + releases[0]?.(); + while (releases.length < 3) { + await Bun.sleep(1); + } + expect(starts.map((start) => start.id)).toEqual([ + "alpha", + "bravo", + "charlie", + ]); + releases[1]?.(); + while (releases.length < 4) { + await Bun.sleep(1); + } + releases[2]?.(); + releases[3]?.(); + await running; + expect(peak).toBe(2); + expect(starts.map((start) => start.at)).toEqual([0, 500, 1000, 1500]); +}); + +test("shutdown cancellation prevents any further launch", async () => { + await seed(["alpha", "bravo"]); + const abort = new AbortController(); + deps = { + ...deps, + start: async (project) => { + starts.push({ id: project.id, at: clock }); + abort.abort(); + return { status: "started", reason: null }; + }, + }; + await run(abort.signal); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); +}); + +test("only classified safe reasons are persisted; missing keys and prompts are skipped", () => { + for (const [code, reason] of [ + ["E_INTERACTIVE_REQUIRED", "interactive_required"], + ["E_ENV_KEY_MISSING", "env_key_missing"], + ] as const) { + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ + ok: false, + error: { code, message: "sensitive example output" }, + }), + }) + ).toEqual({ status: "skipped", reason }); + } + expect( + classifyAutostartExit({ exitCode: 1, stdout: "sensitive example output" }) + ).toEqual({ status: "failed", reason: "start_failed" }); + expect(classifyAutostartExit({ exitCode: 124, stdout: "" })).toEqual({ + status: "failed", + reason: "start_timeout", + }); +}); + +test("production runner has no input terminal or keychain and does not retain stderr", async () => { + const result = await runAutostartSubprocess( + [ + process.execPath, + "--eval", + ` + process.stderr.write("example-sensitive-output"); + process.stdout.write(JSON.stringify({ + input: await Bun.stdin.text(), + tty: process.stdin.isTTY === true, + interactive: process.env.HACK_NO_INTERACTIVE, + keychain: process.env.HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK, + daemon: process.env.HACK_DAEMON_AUTOSTART_DISABLE, + login: process.env.HACK_AUTOSTART_LOGIN, + home: process.env.HOME, + key: process.env.HACK_ENV_SECRET_KEY ?? null, + })); + `, + ], + { + timeoutMs: 5000, + env: { + HOME: root, + HACK_HOME: join(root, "state"), + HACK_ENV_SECRET_KEY: "synthetic-key-material", + }, + } + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + input: "", + tty: false, + interactive: "1", + keychain: "1", + daemon: "1", + login: "1", + home: root, + key: null, + }); + expect(JSON.stringify(result)).not.toContain("example-sensitive-output"); +}); + +test("production runner enforces output and time bounds", async () => { + const env = { HOME: root, HACK_HOME: join(root, "state") }; + expect( + await runAutostartSubprocess( + [process.execPath, "--eval", 'process.stdout.write("x".repeat(70000))'], + { timeoutMs: 5000, env } + ) + ).toEqual({ exitCode: 124, stdout: "", reason: "output_limit" }); + expect( + await runAutostartSubprocess( + [process.execPath, "--eval", "await Bun.sleep(10000)"], + { timeoutMs: 30, env } + ) + ).toEqual({ exitCode: 124, stdout: "", reason: "start_timeout" }); +}); + +test("migration and missing project refusals have stable safe skip reasons", () => { + const failure = (code: string, detail?: unknown) => + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ + ok: false, + error: { code, detail, message: "example-sensitive-output" }, + }), + }); + expect( + failure("E_CONFIG_INVALID", { reason: "pending_env_migration" }) + ).toEqual({ status: "skipped", reason: "pending_env_migration" }); + expect( + failure("E_CONFIG_INVALID", { reason: "example-sensitive-output" }) + ).toEqual({ status: "failed", reason: "start_failed" }); + expect(failure("E_PROJECT_NOT_FOUND")).toEqual({ + status: "skipped", + reason: "project_unavailable", + }); +}); + +test("native selection is explicitly refused before any Docker or routing probe", async () => { + expect( + autostartRuntimeRefusal({ + HACK_RUNTIME_BACKEND: "native", + HACK_NATIVE_BINARY: "/fixture/runtime", + HACK_NATIVE_HOME: "/fixture/state", + }) + ).toBe("native_unsupported"); + expect(autostartRuntimeRefusal({ HACK_RUNTIME_BACKEND: "native" })).toBe( + "native_unsupported" + ); + expect( + autostartRuntimeRefusal({ HACK_RUNTIME_BACKEND: "compose" }) + ).toBeNull(); + await seed(["alpha"]); + deps = { ...deps, startupRefusal: () => "native_unsupported" }; + await run(); + expect(events).toEqual([]); + expect(starts).toEqual([]); + expect((await results())[0]?.result).toMatchObject({ + status: "skipped", + reason: "native_unsupported", + }); +}); + +test("successful one-shot containers are adopted without replaying hooks", () => { + expect(classifyAutostartContainers("running|0\nexited|0\n")).toBe("running"); + expect(classifyAutostartContainers("exited|0\n")).toBe("stopped"); + expect(classifyAutostartContainers("exited|0\nexited|0\n")).toBe("stopped"); + expect(classifyAutostartContainers("running|0\nexited|1\n")).toBe("stopped"); + expect(classifyAutostartContainers("created|0\n")).toBe("stopped"); + expect(classifyAutostartContainers("invalid response")).toBe("unknown"); + expect(classifyAutostartContainers("")).toBe("unknown"); +}); + +test("keychain preflight and subprocess termination have distinct closed reasons", async () => { + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ + error: { + code: "E_CONFIG_INVALID", + detail: { reason: "keychain_unavailable" }, + }, + }), + }) + ).toEqual({ status: "skipped", reason: "keychain_unavailable" }); + for (const reason of [ + "output_limit", + "start_timeout", + "daemon_stopped", + ] as const) { + expect( + classifyAutostartExit({ exitCode: 124, stdout: "", reason }) + ).toEqual({ + status: reason === "daemon_stopped" ? "skipped" : "failed", + reason, + }); + } + const abort = new AbortController(); + abort.abort(); + expect( + await runAutostartSubprocess( + [process.execPath, "--eval", "throw Error('must not run')"], + { timeoutMs: 1000, signal: abort.signal, env: { HOME: root } } + ) + ).toEqual({ exitCode: 124, stdout: "", reason: "daemon_stopped" }); +}); diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts new file mode 100644 index 000000000..de990d18a --- /dev/null +++ b/tests/project-autostart.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, 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 { + claimProjectAutostart, + readProjectsRegistry, + readProjectsRegistryStrict, + recordProjectAutostartResult, + removeProjectAutostart, + setProjectAutostart, + setProjectAutostartPaused, + upsertProjectRegistration, +} from "../src/lib/projects-registry.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +let root = ""; +let registryPath = ""; +let previousHome: string | undefined; +let previousConfig: string | undefined; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-autostart-registry-")); + registryPath = join(root, ".hack", "projects.json"); + previousHome = process.env.HOME; + previousConfig = process.env.HACK_GLOBAL_CONFIG_PATH; + process.env.HOME = root; + process.env.HACK_GLOBAL_CONFIG_PATH = join(root, ".hack", "hack.config.json"); + await mkdir(join(root, ".hack"), { recursive: true }); + await writeFile( + registryPath, + JSON.stringify({ version: 1, projects: [entry("alpha"), entry("beta")] }) + ); +}); + +afterEach(async () => { + restoreEnv("HOME", previousHome); + restoreEnv("HACK_GLOBAL_CONFIG_PATH", previousConfig); + await rm(root, { recursive: true, force: true }); +}); + +function entry(name: string) { + return { + id: name, + name, + repoRoot: join(root, name), + projectDir: join(root, name, ".hack"), + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +async function read(id = "alpha") { + return (await readProjectsRegistry({ registryPath })).projects.find( + (item) => item.id === id + ); +} + +test("old registries have no intent; explicit opt-in migrates only the selected entry", async () => { + expect((await read())?.autostart).toBeUndefined(); + const marked = await setProjectAutostart({ + id: "alpha", + branch: "preview", + registryPath, + }); + expect(marked.autostart).toEqual({ + branch: "preview", + paused: false, + lastResult: null, + lastAttemptBootId: null, + }); + expect((await read("beta"))?.autostart).toBeUndefined(); + expect((await read())?.autostart).toEqual(marked.autostart); + await removeProjectAutostart({ id: "alpha", registryPath }); + expect((await read())?.autostart).toBeUndefined(); + expect((await read())?.name).toBe("alpha"); +}); + +test("pause all changes only marked entries; up resume cannot create intent", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await setProjectAutostartPaused({ paused: true, registryPath }); + expect((await read())?.autostart?.paused).toBe(true); + expect((await read("beta"))?.autostart).toBeUndefined(); + await setProjectAutostartPaused({ id: "beta", paused: false, registryPath }); + expect((await read("beta"))?.autostart).toBeUndefined(); + await setProjectAutostartPaused({ paused: false, registryPath }); + expect((await read())?.autostart?.paused).toBe(false); +}); + +test("claims persist once per boot across concurrent schedulers and registry rereads", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + const attempts = await Promise.all( + Array.from({ length: 4 }, () => + claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ) + ); + expect(attempts.filter(Boolean)).toHaveLength(1); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); + expect( + ( + await claimProjectAutostart({ + id: "alpha", + bootId: "boot-b", + registryPath, + }) + )?.autostart?.lastAttemptBootId + ).toBe("boot-b"); +}); + +test("down pause survives daemon restart and a new boot until an explicit up", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-b", registryPath }) + ).toBeNull(); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-b", registryPath }) + ).not.toBeNull(); +}); + +test("late results preserve pause and cannot recreate removed intent or overwrite another boot", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); + const result = { + status: "skipped" as const, + reason: "interaction_required", + at: "2026-01-01T00:00:00.000Z", + }; + expect( + await recordProjectAutostartResult({ + id: "alpha", + bootId: "boot-a", + result, + registryPath, + }) + ).toBe(true); + expect((await read())?.autostart).toMatchObject({ + paused: true, + lastResult: result, + }); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-b", registryPath }); + expect( + await recordProjectAutostartResult({ + id: "alpha", + bootId: "boot-a", + result, + registryPath, + }) + ).toBe(false); + await removeProjectAutostart({ id: "alpha", registryPath }); + expect( + await recordProjectAutostartResult({ + id: "alpha", + bootId: "boot-b", + result, + registryPath, + }) + ).toBe(false); + expect((await read())?.autostart).toBeUndefined(); +}); + +test("ordinary registration refresh preserves intent and startup result", async () => { + const projectDir = join(root, "alpha", ".hack"); + await mkdir(projectDir, { recursive: true }); + const configFile = join(projectDir, "hack.config.json"); + const composeFile = join(projectDir, "docker-compose.yml"); + const envFile = join(projectDir, ".env"); + await writeFile(configFile, JSON.stringify({ name: "alpha" })); + await writeFile(composeFile, "services: {}\n"); + await writeFile(envFile, ""); + await setProjectAutostart({ id: "alpha", registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + await recordProjectAutostartResult({ + id: "alpha", + bootId: "boot-a", + registryPath, + result: { status: "started", reason: null, at: "2026-01-01T00:00:00.000Z" }, + }); + const marked = await read(); + await upsertProjectRegistration({ + project: { + projectRoot: join(root, "alpha"), + projectDir, + projectDirName: ".hack", + composeFile, + envFile, + configFile, + }, + }); + expect((await read())?.autostart).toEqual(marked?.autostart); +}); + +test("malformed intent disables only autostart and removal repairs it", async () => { + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [{ ...entry("alpha"), autostart: { paused: "false" } }], + }) + ); + const before = await readFile(registryPath, "utf8"); + expect((await read())?.name).toBe("alpha"); + expect((await read())?.autostart).toMatchObject({ + paused: true, + invalid: true, + lastResult: { + status: "skipped", + reason: "invalid_marker", + at: "1970-01-01T00:00:00.000Z", + }, + }); + expect(await readProjectsRegistryStrict({ registryPath })).toMatchObject({ + kind: "valid", + rejected: 0, + invalidAutostart: 1, + }); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + expect(await readFile(registryPath, "utf8")).toBe(before); + await expect( + setProjectAutostart({ id: "alpha", registryPath }) + ).rejects.toThrow("Remove the marker"); + await removeProjectAutostart({ id: "alpha", registryPath }); + expect((await read())?.autostart).toBeUndefined(); + await setProjectAutostart({ id: "alpha", registryPath }); + expect((await read())?.autostart?.paused).toBe(false); +}); + +test("unrelated intent writes preserve invalid and future markers exactly", async () => { + const future = { + branch: "preview", + paused: false, + lastAttemptBootId: "previous-boot", + lastResult: { + status: "future_status", + reason: null, + at: "2026-01-01T00:00:00.000Z", + extra: { keep: true }, + }, + futureField: "keep", + }; + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [{ ...entry("alpha"), autostart: future }, entry("beta")], + }) + ); + expect((await read())?.autostart?.paused).toBe(true); + expect( + await claimProjectAutostart({ + id: "alpha", + bootId: "new-boot", + registryPath, + }) + ).toBeNull(); + await setProjectAutostart({ id: "beta", registryPath }); + const persisted = JSON.parse(await readFile(registryPath, "utf8")); + expect(persisted.projects[0].autostart).toEqual(future); + await removeProjectAutostart({ id: "alpha", registryPath }); + expect((await read())?.autostart).toBeUndefined(); +}); + +test("ordinary upsert preserves an invalid marker without blocking the project", async () => { + const raw = { paused: "invalid", future: [1, 2, 3] }; + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [{ ...entry("alpha"), autostart: raw }], + }) + ); + const projectDir = join(root, "alpha", ".hack"); + await mkdir(projectDir, { recursive: true }); + const configFile = join(projectDir, "hack.config.json"); + const composeFile = join(projectDir, "docker-compose.yml"); + const envFile = join(projectDir, ".env"); + await writeFile(configFile, JSON.stringify({ name: "alpha" })); + await writeFile(composeFile, "services: {}\n"); + await writeFile(envFile, ""); + await upsertProjectRegistration({ + project: { + projectRoot: join(root, "alpha"), + projectDir, + projectDirName: ".hack", + composeFile, + envFile, + configFile, + }, + }); + const persisted = JSON.parse(await readFile(registryPath, "utf8")); + expect(persisted.projects[0].autostart).toEqual(raw); + expect((await read())?.name).toBe("alpha"); +}); + +test("known markers roundtrip unknown fields through pause and start claims", async () => { + const marker = { + branch: null, + paused: false, + lastAttemptBootId: null, + lastResult: { + status: "started", + reason: null, + at: "2026-01-01T00:00:00.000Z", + futureResult: 42, + }, + futureMarker: { keep: true }, + }; + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [{ ...entry("alpha"), autostart: marker }], + }) + ); + await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + await claimProjectAutostart({ + id: "alpha", + bootId: "new-boot", + registryPath, + }); + const persisted = JSON.parse(await readFile(registryPath, "utf8")); + expect(persisted.projects[0].autostart).toEqual({ + ...marker, + lastAttemptBootId: "new-boot", + }); +}); + +test("no mutation loses invalid root registry entries", async () => { + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [entry("alpha"), { invalid: true }], + }) + ); + await expect( + setProjectAutostart({ id: "alpha", registryPath }) + ).rejects.toThrow("registry is invalid"); +}); + +test("unknown and ambiguous ids cannot opt in a different project", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await expect(setProjectAutostart({ id: "", registryPath })).rejects.toThrow( + "not registered" + ); + await expect( + setProjectAutostart({ id: "absent", registryPath }) + ).rejects.toThrow("not registered"); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [entry("alpha"), { ...entry("beta"), id: "alpha" }], + }) + ); + await expect( + setProjectAutostart({ id: "alpha", registryPath }) + ).rejects.toThrow("Ambiguous project ID"); +}); diff --git a/tests/status-native.test.ts b/tests/status-native.test.ts index a629e3f1d..a35b4443f 100644 --- a/tests/status-native.test.ts +++ b/tests/status-native.test.ts @@ -70,6 +70,29 @@ test("hack status under native selection observes the cwd project like native ps const scopes: unknown[] = []; const out = await captureStdout(async () => { const code = await runNativeStatus({ + readRegistry: async () => ({ + version: 1, + projects: [ + { + id: "native-example", + name: "example", + repoRoot: root, + projectDir: join(root, ".hack"), + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + autostart: { + branch: null, + paused: true, + lastResult: { + status: "skipped", + reason: "interactive_required", + at: "2026-01-01T00:00:00Z", + }, + lastAttemptBootId: "boot-example", + }, + }, + ], + }), cwd: root, native, projectOpt: undefined, @@ -97,10 +120,21 @@ test("hack status under native selection observes the cwd project like native ps // The JSON envelope matches native `hack ps --json`; nothing reports "stopped". const parsed = JSON.parse(out) as { ok: boolean; - data: NativeProjectPsResult; + data: NativeProjectPsResult & { readonly autostart: unknown }; }; expect(parsed.ok).toBe(true); - expect(parsed.data).toEqual(observed); + expect(parsed.data).toEqual({ + ...observed, + autostart: { + branch: null, + paused: true, + last_result: { + status: "skipped", + reason: "interactive_required", + at: "2026-01-01T00:00:00Z", + }, + }, + }); expect(out).not.toContain("stopped"); }); @@ -112,6 +146,7 @@ test("hack status under native selection refuses outside a project instead of li let observations = 0; await expect( runNativeStatus({ + readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native: { binary: "/opt/candidate/hack-native", home: root }, projectOpt: undefined, @@ -125,6 +160,7 @@ test("hack status under native selection refuses outside a project instead of li ).rejects.toBeInstanceOf(CliUsageError); await expect( runNativeStatus({ + readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native: { binary: "/opt/candidate/hack-native", home: root }, projectOpt: undefined, @@ -155,11 +191,13 @@ test("hack status under native selection dispatches a native-authored project li const dispatched: unknown[] = []; let observations = 0; const code = await runNativeStatus({ + readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native, projectOpt: undefined, json: true, authored: async (input) => { + expect(await input.statusMetadata?.()).toEqual({ autostart: null }); dispatched.push(input); return 7; }, @@ -172,11 +210,18 @@ test("hack status under native selection dispatches a native-authored project li expect(code).toBe(7); expect(observations).toBe(0); expect(dispatched).toEqual([ - { cwd: root, project: undefined, operation: "ps", json: true }, + { + cwd: root, + project: undefined, + operation: "ps", + json: true, + statusMetadata: expect.any(Function), + }, ]); // Control: without the authored dispatch this checkout is refused before any observation. await expect( runNativeStatus({ + readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native, projectOpt: undefined, From 9cfbaa686c609e8b61b9e189395d2917a9df00f3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:19:21 +0000 Subject: [PATCH 02/44] fix(autostart): preserve native dispatch and identify completed jobs --- docs/always-on-projects.md | 4 + src/commands/project-autostart-intent.ts | 116 +++++++++++++++++++++++ src/commands/project.ts | 63 +++++------- src/daemon/autostart.ts | 67 +++++++++++-- tests/autostart-lifecycle-intent.test.ts | 36 ++++++- tests/daemon-autostart.test.ts | 61 ++++++++++-- 6 files changed, 292 insertions(+), 55 deletions(-) create mode 100644 src/commands/project-autostart-intent.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index bbac87aa3..c488c5365 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -44,6 +44,10 @@ at least 500 milliseconds apart, with at most two in progress. Each uses the running daemon's CLI executable and the ordinary `up -d --no-interactive` path. Already-running instances are adopted without repeating startup hooks. An unknown runtime state is skipped rather than taken as proof that the instance is down. +Adoption requires a running container. An exited-zero container is treated as a +completed job only when its service is a dependency target explicitly declared +with `condition: service_completed_successfully`; other stopped services require +`up`. This slice reads that declaration from the primary Compose file. No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts new file mode 100644 index 000000000..6085a29d1 --- /dev/null +++ b/src/commands/project-autostart-intent.ts @@ -0,0 +1,116 @@ +import { resolve } from "node:path"; +import { CliUsageError } from "../cli/command.ts"; +import { + PROJECT_ENV_CONTRACT_FILENAME, + PROJECT_ENV_FILENAME, +} from "../constants.ts"; +import { HackCliError } from "../lib/cli-result.ts"; +import { pathExists } from "../lib/fs.ts"; +import { type ProjectContext, sanitizeBranchSlug } from "../lib/project.ts"; +import { + discoverComposeServiceNames, + findUnmigratedLegacyDotenv, + hasUnmigratedLegacyDotenv, + projectEnvConfigExists, +} from "../lib/project-env-config.ts"; +import { discoverProjectInputs } from "../lib/project-input-selection.ts"; +import { normalizeProjectName } from "../lib/project-name.ts"; +import { + type RegisteredProject, + setProjectAutostartPaused, +} from "../lib/projects-registry.ts"; + +/** Login must never continue with a legacy environment requiring migration. */ +export async function assertAutostartEnvironment(opts: { + readonly project: ProjectContext; +}): Promise { + const project = opts.project; + const contract = await pathExists( + resolve(project.projectDir, PROJECT_ENV_CONTRACT_FILENAME) + ); + const modern = await projectEnvConfigExists({ + projectDir: project.projectDir, + }); + const legacy = await findUnmigratedLegacyDotenv({ + projectRoot: project.projectRoot, + projectDir: project.projectDir, + serviceNames: await discoverComposeServiceNames({ + composeFile: project.composeFile, + }), + }); + const dotenv = Bun.file(resolve(project.projectDir, PROJECT_ENV_FILENAME)); + const legacyDotenv = !modern && (await dotenv.exists()) && dotenv.size > 0; + if ( + (!modern && contract) || + legacyDotenv || + hasUnmigratedLegacyDotenv(legacy) + ) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Autostart skipped: pending env migration. Run hack doctor --migrate-env-config.", + detail: { reason: "pending_env_migration" }, + }); + } +} + +/** Match canonical registry identity without entering legacy project discovery. */ +export function findAutostartRegistration(opts: { + readonly projects: readonly RegisteredProject[]; + readonly selectorName?: string; + readonly startDir: string; +}): RegisteredProject | undefined { + return opts.projects + .filter((entry) => entry.autostart) + .find((entry) => + opts.selectorName + ? normalizeProjectName(entry.name) === opts.selectorName + : [ + entry.repoRoot, + ...(entry.worktrees?.map((worktree) => worktree.path) ?? []), + ].some( + (path) => + opts.startDir === path || opts.startDir.startsWith(`${path}/`) + ) + ); +} + +/** Native/adopted dispatch owns its context; machine pause intent needs only its registry ID. */ +export async function updateNativeAutostartIntent(opts: { + readonly project: RegisteredProject; + readonly selectedRoot: string; + readonly action: "up" | "down"; + readonly branch?: string; + readonly always?: boolean; + readonly login: boolean; +}): Promise { + const inputs = await discoverProjectInputs({ startDir: opts.selectedRoot }); + if (inputs?.kind !== "native" && inputs?.kind !== "conflict") { + return false; + } + if (opts.login || opts.always) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Autostart supports Compose projects only; start native projects manually.", + detail: { reason: "native_unsupported" }, + }); + } + const rawBranch = opts.branch?.trim(); + const branch = rawBranch ? sanitizeBranchSlug(rawBranch) : null; + if (rawBranch && !branch) { + throw new CliUsageError("Invalid branch instance."); + } + const selected = + inputs.projectRoot === opts.project.repoRoot || + opts.project.worktrees?.some( + (worktree) => worktree.path === inputs.projectRoot + ); + if (selected && opts.project.autostart?.branch === branch) { + await setProjectAutostartPaused({ + id: opts.project.id, + paused: opts.action === "down", + }); + } + return true; +} diff --git a/src/commands/project.ts b/src/commands/project.ts index 9f9bda93d..9924a8921 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -341,7 +341,6 @@ import { assertLegacyProjectDiscovery, assertLegacyProjectInputFamily, } from "../lib/project-input-selection.ts"; -import { normalizeProjectName } from "../lib/project-name.ts"; import { readProjectRuntimeStateEntry, removeProjectRuntimeStateEntry, @@ -425,6 +424,11 @@ import { import { noteNativeBackendBeta } from "./native-beta-notice.ts"; import { displayNativePs } from "./native-ps-display.ts"; import { answerNativeAlreadyRunning } from "./native-up-already-running.ts"; +import { + assertAutostartEnvironment, + findAutostartRegistration, + updateNativeAutostartIntent, +} from "./project-autostart-intent.ts"; import { prepareDisposableCachePrune, reconcileImplicitDownBranch, @@ -6418,19 +6422,27 @@ async function updateAutostartIntent(opts: { const startDir = await realpath(requestedStartDir).catch( () => requestedStartDir ); - const marked = registry.projects - .filter((entry) => entry.autostart) - .find((entry) => - selector - ? normalizeProjectName(entry.name) === selector.name - : [ - entry.repoRoot, - ...(entry.worktrees?.map((worktree) => worktree.path) ?? []), - ].some((path) => startDir === path || startDir.startsWith(`${path}/`)) - ); + const marked = findAutostartRegistration({ + projects: registry.projects, + selectorName: selector?.name, + startDir, + }); if (!(login || opts.args.options.always || marked)) { return; } + if ( + marked && + (await updateNativeAutostartIntent({ + project: marked, + selectedRoot: selector ? marked.repoRoot : startDir, + action: opts.action, + branch: opts.args.options.branch, + always: opts.args.options.always, + login, + })) + ) { + return; + } const project = await resolveProjectForArgs({ ctx: opts.ctx, pathOpt: opts.args.options.path, @@ -6438,34 +6450,7 @@ async function updateAutostartIntent(opts: { touchRegistration: false, }); if (login) { - // Login must never continue with a legacy environment that needs migration. - const contract = await pathExists( - resolve(project.projectDir, PROJECT_ENV_CONTRACT_FILENAME) - ); - const modern = await projectEnvConfigExists({ - projectDir: project.projectDir, - }); - const legacy = await findUnmigratedLegacyDotenv({ - projectRoot: project.projectRoot, - projectDir: project.projectDir, - serviceNames: await discoverComposeServiceNames({ - composeFile: project.composeFile, - }), - }); - const dotenv = Bun.file(resolve(project.projectDir, PROJECT_ENV_FILENAME)); - const legacyDotenv = !modern && (await dotenv.exists()) && dotenv.size > 0; - if ( - (!modern && contract) || - legacyDotenv || - hasUnmigratedLegacyDotenv(legacy) - ) { - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: - "Autostart skipped: pending env migration. Run hack doctor --migrate-env-config.", - detail: { reason: "pending_env_migration" }, - }); - } + await assertAutostartEnvironment({ project }); return; } // Inspect selection without printing the normal up/down worktree notice twice. diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index f9aa204f7..59416831c 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -1,5 +1,6 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { YAML } from "bun"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; import { GLOBAL_CADDY_COMPOSE_FILENAME, @@ -8,6 +9,7 @@ import { import { childEnvironment } from "../lib/child-environment.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; +import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import { findProjectContextAtRoot, readProjectConfig } from "../lib/project.ts"; import { @@ -290,6 +292,7 @@ export function classifyAutostartExit(opts: { code === "E_CONFIG_INVALID" && isRecord(detail) && (detail.reason === "pending_env_migration" || + detail.reason === "native_unsupported" || detail.reason === "keychain_unavailable") ) { return { status: "skipped", reason: detail.reason }; @@ -439,7 +442,7 @@ async function inspectComposeProject( "docker", "inspect", "--format", - "{{.State.Status}}|{{.State.ExitCode}}", + '{{index .Config.Labels "com.docker.compose.service"}}|{{.State.Status}}|{{.State.ExitCode}}', ...ids, ], { timeoutMs: 5000, signal } @@ -447,12 +450,16 @@ async function inspectComposeProject( if (inspected.exitCode !== 0) { return "unknown"; } - return classifyAutostartContainers(inspected.stdout); + const completedServices = await readCompletedDependencyServices( + context.composeFile + ); + return classifyAutostartContainers(inspected.stdout, completedServices); } -/** Completed successful one-shot services are adopted alongside running services. */ +/** Only declared successful-completion dependency targets may be adopted after exit. */ export function classifyAutostartContainers( - text: string + text: string, + completedServices: ReadonlySet = new Set() ): "running" | "stopped" | "unknown" { const states = text.trim().split("\n").filter(Boolean); if (states.length === 0) { @@ -460,11 +467,18 @@ export function classifyAutostartContainers( } let running = false; for (const state of states) { - const [status, exitCode, extra] = state.split("|"); - if (extra !== undefined || !status || !exitCode) { + const [service, status, exitCode, extra] = state.split("|"); + if (extra !== undefined || !service || !status || !exitCode) { return "unknown"; } - if (status !== "running" && !(status === "exited" && exitCode === "0")) { + if ( + status !== "running" && + !( + status === "exited" && + exitCode === "0" && + completedServices.has(service) + ) + ) { return "stopped"; } running ||= status === "running"; @@ -472,6 +486,45 @@ export function classifyAutostartContainers( return running ? "running" : "stopped"; } +async function readCompletedDependencyServices( + composeFile: string +): Promise> { + const text = await readTextFile(composeFile); + if (text === null) { + return new Set(); + } + try { + return completedDependencyServices(YAML.parse(text)); + } catch { + return new Set(); + } +} + +/** A zero exit or restart policy alone does not establish a one-shot service. */ +export function completedDependencyServices( + compose: unknown +): ReadonlySet { + const completed = new Set(); + if (!(isRecord(compose) && isRecord(compose.services))) { + return completed; + } + for (const service of Object.values(compose.services)) { + if (!(isRecord(service) && isRecord(service.depends_on))) { + continue; + } + for (const [target, dependency] of Object.entries(service.depends_on)) { + if ( + isRecord(dependency) && + dependency.condition === "service_completed_successfully" && + isRecord(compose.services[target]) + ) { + completed.add(target); + } + } + } + return completed; +} + async function readMachineBootId(): Promise { if (process.platform === "linux") { try { diff --git a/tests/autostart-lifecycle-intent.test.ts b/tests/autostart-lifecycle-intent.test.ts index 62d3ed37d..6ab8b583e 100644 --- a/tests/autostart-lifecycle-intent.test.ts +++ b/tests/autostart-lifecycle-intent.test.ts @@ -55,7 +55,12 @@ async function marker() { ).json(); return registry.projects[0].autostart; } -async function run(args: string[], login = false, cwd = join(root, "project")) { +async function run( + args: string[], + login = false, + cwd = join(root, "project"), + overrides: Readonly> = {} +) { const child = Bun.spawn( [process.execPath, cli, "--no-interactive", ...args], { @@ -66,6 +71,7 @@ async function run(args: string[], login = false, cwd = join(root, "project")) { env: { PATH: join(root, "bin"), HACK_AUTOSTART_LOGIN: login ? "1" : "0", + ...overrides, }, }), stdin: "ignore", @@ -174,3 +180,31 @@ test("linked worktree opt-in refuses without marking the primary checkout", asyn expect(add.stdout + add.stderr).toContain("primary checkout"); expect(await marker()).toEqual(before); }); + +test("native selection with an existing marker reaches the same dispatch as an unmarked project", async () => { + await writeFile( + join(root, "project", ".hack", "hack.project.json"), + JSON.stringify({ schema_version: 1, name: "sample", services: {} }) + ); + const registryPath = join(root, "home", ".hack", "projects.json"); + for (const action of ["down", "up"]) { + await writeRegistry(false); + const registry = await Bun.file(registryPath).json(); + Reflect.deleteProperty(registry.projects[0], "autostart"); + await writeFile(registryPath, JSON.stringify(registry)); + const baseline = await run( + [action, "--json"], + false, + join(root, "project"), + { HACK_RUNTIME_BACKEND: "native" } + ); + await writeRegistry(action === "up"); + const marked = await run([action, "--json"], false, join(root, "project"), { + HACK_RUNTIME_BACKEND: "native", + }); + expect(marked.exitCode).toBe(baseline.exitCode); + expect(marked.stdout).toBe(baseline.stdout); + expect(marked.stderr).toBe(baseline.stderr); + expect((await marker()).paused).toBe(action === "down"); + } +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index 8a668ce74..e3aefbd4b 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -7,6 +7,7 @@ import { autostartRuntimeRefusal, classifyAutostartContainers, classifyAutostartExit, + completedDependencyServices, runAutostartAtLogin, runAutostartSubprocess, } from "../src/daemon/autostart.ts"; @@ -380,14 +381,58 @@ test("native selection is explicitly refused before any Docker or routing probe" }); }); -test("successful one-shot containers are adopted without replaying hooks", () => { - expect(classifyAutostartContainers("running|0\nexited|0\n")).toBe("running"); - expect(classifyAutostartContainers("exited|0\n")).toBe("stopped"); - expect(classifyAutostartContainers("exited|0\nexited|0\n")).toBe("stopped"); - expect(classifyAutostartContainers("running|0\nexited|1\n")).toBe("stopped"); - expect(classifyAutostartContainers("created|0\n")).toBe("stopped"); - expect(classifyAutostartContainers("invalid response")).toBe("unknown"); - expect(classifyAutostartContainers("")).toBe("unknown"); +test("only declared completed dependency services are adopted alongside running services", () => { + const completed = completedDependencyServices({ + services: { + init: { restart: "no" }, + web: { + depends_on: { init: { condition: "service_completed_successfully" } }, + restart: "no", + }, + db: {}, + }, + }); + expect([...completed]).toEqual(["init"]); + expect( + classifyAutostartContainers("db|running|0\nweb|exited|0\n", completed) + ).toBe("stopped"); + expect( + classifyAutostartContainers("web|running|0\ninit|exited|0\n", completed) + ).toBe("running"); + expect(classifyAutostartContainers("web|running|0\ninit|exited|0\n")).toBe( + "stopped" + ); + expect(classifyAutostartContainers("init|exited|0\n", completed)).toBe( + "stopped" + ); + expect( + classifyAutostartContainers("web|running|0\ninit|exited|1\n", completed) + ).toBe("stopped"); + expect(classifyAutostartContainers("web|created|0\n", completed)).toBe( + "stopped" + ); + expect(classifyAutostartContainers("invalid response", completed)).toBe( + "unknown" + ); + expect(classifyAutostartContainers("", completed)).toBe("unknown"); +}); + +test("completion targets require a declared service and the exact dependency condition", () => { + expect([ + ...completedDependencyServices({ + services: { + init: {}, + web: { + depends_on: { + init: { condition: "service_started" }, + missing: { condition: "service_completed_successfully" }, + }, + }, + worker: { depends_on: ["init"] }, + }, + }), + ]).toEqual([]); + expect([...completedDependencyServices(null)]).toEqual([]); }); test("keychain preflight and subprocess termination have distinct closed reasons", async () => { From e95d5184ab0930f1c283e1fe9ab405bf07073481 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:28:12 +0000 Subject: [PATCH 03/44] fix(autostart): authenticate registry intent before login starts --- docs/always-on-projects.md | 5 + src/daemon/autostart.ts | 7 +- src/lib/projects-registry.ts | 149 +++++++++++++++-- tests/autostart-lifecycle-intent.test.ts | 3 +- tests/daemon-autostart.test.ts | 24 ++- tests/project-autostart.test.ts | 196 ++++++++++++++++++++++- 6 files changed, 366 insertions(+), 18 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index c488c5365..b542be6b8 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -53,6 +53,11 @@ No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a recorded result. Run `hack up` manually to investigate and recover. The daemon does not install routing, start Docker, sign in, or migrate configuration at login. +Before trusting intent, Hack opens `projects.json` without following links and +requires a regular file owned by the current user, with no group or other write +access. That check repeats at every autostart read and mutation. Unsafe registry +permissions refuse startup; inspect ownership and set the registry mode to `0600` +before retrying. Ordinary project listings remain readable for diagnosis. A claim is saved before each attempt, once per OS boot. Restarting the daemon in the same boot does not repeat starts. A crash after the claim can leave an diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index 59416831c..da88e4996 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -68,6 +68,7 @@ export async function runAutostartAtLogin( const deps = opts.dependencies ?? productionDependencies(); const registry = await readProjectsRegistry({ registryPath: opts.registryPath, + requireAutostartTrust: true, }); const candidates = registry.projects .filter((project) => project.autostart && !project.autostart.paused) @@ -202,6 +203,7 @@ async function startClaimedProject(opts: { try { const registry = await readProjectsRegistry({ registryPath: opts.registryPath, + requireAutostartTrust: true, }); const current = registry.projects.find( (project) => project.id === opts.project.id @@ -246,7 +248,10 @@ async function dispatchIfStillWanted(opts: { readonly registryPath?: string; }): Promise { const latest = ( - await readProjectsRegistry({ registryPath: opts.registryPath }) + await readProjectsRegistry({ + registryPath: opts.registryPath, + requireAutostartTrust: true, + }) ).projects.find((project) => project.id === opts.project.id); if (!latest?.autostart || latest.autostart.paused || opts.signal?.aborted) { return { diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 4c17236c0..7166b54be 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -1,5 +1,7 @@ +import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; -import { realpath } from "node:fs/promises"; +import { constants } from "node:fs"; +import { type FileHandle, open, realpath } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { GLOBAL_PROJECTS_REGISTRY_FILENAME } from "../constants.ts"; import { HackCliError } from "./cli-result.ts"; @@ -39,6 +41,7 @@ import { const REGISTRY_VERSION = 1 as const; const REGISTRY_LOCK_FILENAME = `${GLOBAL_PROJECTS_REGISTRY_FILENAME}.lock`; const REGISTRY_TOUCH_INTERVAL_MS = 60_000; +const AUTOSTART_REGISTRY_LIMIT_BYTES = 16 * 1024 * 1024; export interface RegisteredProjectWorktree { readonly path: string; @@ -167,6 +170,101 @@ async function readRegistryText(path: string): Promise { } } +/** Value-free refusal: an untrusted registry must never authorize login code execution. */ +export class AutostartRegistryTrustError extends HackCliError { + constructor() { + super({ + code: "E_PERMISSION", + message: + "Autostart cannot trust projects.json. Use a regular file owned by the current user with mode 0600, without a symbolic link; keep it below 16 MiB. Values omitted.", + }); + this.name = "AutostartRegistryTrustError"; + } +} + +/** + * Trust the opened file, never a pre-open path observation. The UID check applies + * on hosts with a user identity; Unix file modes must deny group/other writes. + * Kept pure so owner rejection can be tested without changing real ownership. + */ +export function isTrustedAutostartRegistryFile(opts: { + readonly regular: boolean; + readonly fileUid: number; + readonly currentUid: number | undefined; + readonly mode: number; + readonly size: number; +}): boolean { + return ( + opts.regular && + (opts.currentUid === undefined || opts.fileUid === opts.currentUid) && + (opts.mode & 0o022) === 0 && + opts.size >= 0 && + opts.size <= AUTOSTART_REGISTRY_LIMIT_BYTES + ); +} + +/** No-follow, non-blocking and bounded read from the same descriptor we validate. */ +async function readTrustedAutostartRegistryText( + path: string +): Promise { + let file: FileHandle; + try { + file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + } catch (error: unknown) { + const errno = isRecord(error) ? error.code : undefined; + if (errno === "ENOENT" || errno === "ENOTDIR") { + return null; + } + throw new AutostartRegistryTrustError(); + } + try { + const info = await file.stat(); + if ( + !isTrustedAutostartRegistryFile({ + regular: info.isFile(), + fileUid: info.uid, + currentUid: process.getuid?.(), + mode: info.mode, + size: info.size, + }) + ) { + throw new AutostartRegistryTrustError(); + } + const chunks: Buffer[] = []; + const buffer = Buffer.alloc(64 * 1024); + let total = 0; + while (true) { + const { bytesRead } = await file.read( + buffer, + 0, + Math.min(buffer.length, AUTOSTART_REGISTRY_LIMIT_BYTES + 1 - total), + null + ); + if (bytesRead === 0) { + return Buffer.concat(chunks, total).toString("utf8"); + } + total += bytesRead; + if (total > AUTOSTART_REGISTRY_LIMIT_BYTES) { + throw new AutostartRegistryTrustError(); + } + chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); + } + } catch { + throw new AutostartRegistryTrustError(); + } finally { + await file.close().catch(() => undefined); + } +} + +type RegistryReadOptions = { + readonly registryPath?: string; + /** Automatic starts require a private, current-user-owned regular registry file. */ + readonly requireAutostartTrust?: true; +}; + /** * Read an explicit session registry without changing process-wide environment. * @@ -174,12 +272,16 @@ async function readRegistryText(path: string): Promise { * JSON or not a registry. A registry that exists but cannot be read refuses * with {@link ProjectsRegistryReadError}; writers reread under the lock, so * they refuse too instead of replacing it. + * `requireAutostartTrust` additionally refuses unsafe file identity, mode or + * size with {@link AutostartRegistryTrustError}, before parsing any intent. */ -export async function readProjectsRegistry(opts?: { - readonly registryPath?: string; -}): Promise { +export async function readProjectsRegistry( + opts?: RegistryReadOptions +): Promise { const path = opts?.registryPath ?? getRegistryPath(); - const text = await readRegistryText(path); + const text = opts?.requireAutostartTrust + ? await readTrustedAutostartRegistryText(path) + : await readRegistryText(path); if (!text) { return { version: REGISTRY_VERSION, projects: [] }; } @@ -200,10 +302,11 @@ export async function readProjectsRegistry(opts?: { * registry for an empty one (HACK-2177): `absent` (no file), `valid`, or * `invalid` (not JSON, or not a registry). A file that cannot be read * throws {@link ProjectsRegistryReadError}, as {@link readProjectsRegistry}. + * Trusted autostart reads also throw {@link AutostartRegistryTrustError}. */ -export async function readProjectsRegistryStrict(opts?: { - readonly registryPath?: string; -}): Promise< +export async function readProjectsRegistryStrict( + opts?: RegistryReadOptions +): Promise< | { readonly kind: "absent" | "invalid" } | { readonly kind: "valid"; @@ -214,7 +317,10 @@ export async function readProjectsRegistryStrict(opts?: { readonly invalidAutostart: number; } > { - const text = await readRegistryText(opts?.registryPath ?? getRegistryPath()); + const path = opts?.registryPath ?? getRegistryPath(); + const text = opts?.requireAutostartTrust + ? await readTrustedAutostartRegistryText(path) + : await readRegistryText(path); if (text === null) { return { kind: "absent" }; } @@ -259,6 +365,20 @@ export async function readProjectsRegistryStrict(opts?: { }; } +/** + * Ordinary registry writers must not launder untrusted login intent into a + * current-user-owned atomic replacement. Call only while holding the registry + * lock; if the first read carries intent, build the update from a trusted fresh + * read instead. If it carries none, a later path replacement cannot inject a + * marker into the already-read state that this writer publishes. + */ +async function readProjectsRegistryForMutation(): Promise { + const observed = await readProjectsRegistry(); + return observed.projects.some((project) => project.autostart) + ? await readProjectsRegistry({ requireAutostartTrust: true }) + : observed; +} + type RegistrationUpsertOptions = { readonly project: ProjectContext; readonly nowIso?: string; @@ -330,7 +450,7 @@ async function upsertObservedRegistration(input: { await assertLegacyProjectDirectory({ projectDir: opts.project.projectDir, }); - const current = await readProjectsRegistry(); + const current = await readProjectsRegistryForMutation(); const update = await upsertInMemory({ current, nowIso, @@ -419,7 +539,7 @@ async function refreshUnchangedPrimary(opts: { await assertLegacyProjectDirectory({ projectDir: opts.observation.projectDir, }); - const current = await readProjectsRegistry(); + const current = await readProjectsRegistryForMutation(); const primary = selectUnchangedPrimary({ registry: current, observation: opts.observation, @@ -791,7 +911,10 @@ async function mutateProjectAutostart( lockPath: `${registryPath}.lock`, signal: opts.signal, run: async () => { - const read = await readProjectsRegistryStrict({ registryPath }); + const read = await readProjectsRegistryStrict({ + registryPath, + requireAutostartTrust: true, + }); if ( read.kind === "invalid" || (read.kind === "valid" && read.rejected > 0) @@ -855,7 +978,7 @@ export async function removeProjectsById(opts: { const removeIds = new Set(opts.ids); return await withRegistryLock( async () => { - const current = await readProjectsRegistry(); + const current = await readProjectsRegistryForMutation(); for (const id of removeIds) { selectRegisteredProjectById({ projects: current.projects, id }); } diff --git a/tests/autostart-lifecycle-intent.test.ts b/tests/autostart-lifecycle-intent.test.ts index 6ab8b583e..ccd63073a 100644 --- a/tests/autostart-lifecycle-intent.test.ts +++ b/tests/autostart-lifecycle-intent.test.ts @@ -46,7 +46,8 @@ async function writeRegistry(paused: boolean) { }, }, ], - }) + }), + { mode: 0o600 } ); } async function marker() { diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index e3aefbd4b..26026f0bd 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -80,6 +80,7 @@ async function seed( })), }) ); + await chmod(registryPath, 0o600); } async function run(signal?: AbortSignal): Promise { @@ -124,6 +125,27 @@ test("a daemon restart in the same boot never dispatches twice; a new boot can r expect(starts.map((start) => start.id)).toEqual(["alpha", "alpha"]); }); +test("an untrusted registry cannot trigger even an engine probe or start", async () => { + await seed(["alpha"]); + await chmod(registryPath, 0o660); + await expect(run()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(events).toEqual([]); + expect(starts).toEqual([]); +}); + +test("trust is rechecked after observation immediately before dispatch", async () => { + await seed(["alpha"]); + deps = { + ...deps, + inspect: async () => { + await chmod(registryPath, 0o660); + return "stopped"; + }, + }; + await expect(run()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(starts).toEqual([]); +}); + test("paused projects and down followed by daemon restart are not started", async () => { await seed(["alpha", "bravo"]); await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index de990d18a..ace8bf612 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -1,15 +1,29 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rename, + rm, + stat, + symlink, + truncate, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { claimProjectAutostart, + isTrustedAutostartRegistryFile, readProjectsRegistry, readProjectsRegistryStrict, recordProjectAutostartResult, removeProjectAutostart, + removeProjectsById, setProjectAutostart, setProjectAutostartPaused, + touchProjectRegistration, upsertProjectRegistration, } from "../src/lib/projects-registry.ts"; import { restoreEnv } from "./helpers/env.ts"; @@ -29,7 +43,8 @@ beforeEach(async () => { await mkdir(join(root, ".hack"), { recursive: true }); await writeFile( registryPath, - JSON.stringify({ version: 1, projects: [entry("alpha"), entry("beta")] }) + JSON.stringify({ version: 1, projects: [entry("alpha"), entry("beta")] }), + { mode: 0o600 } ); }); @@ -369,3 +384,180 @@ test("unknown and ambiguous ids cannot opt in a different project", async () => setProjectAutostart({ id: "alpha", registryPath }) ).rejects.toThrow("Ambiguous project ID"); }); + +test("autostart trusted reads accept a private registry and missing registry", async () => { + expect( + await readProjectsRegistry({ registryPath, requireAutostartTrust: true }) + ).toMatchObject({ projects: [{ id: "alpha" }, { id: "beta" }] }); + const missing = join(root, "absent.json"); + expect( + await readProjectsRegistry({ + registryPath: missing, + requireAutostartTrust: true, + }) + ).toEqual({ version: 1, projects: [] }); + expect( + await readProjectsRegistryStrict({ + registryPath: missing, + requireAutostartTrust: true, + }) + ).toEqual({ kind: "absent" }); +}); + +test("autostart rejects group and other writable registries while ordinary reads still work", async () => { + for (const mode of [0o660, 0o606]) { + await chmod(registryPath, mode); + expect( + (await readProjectsRegistry({ registryPath })).projects + ).toHaveLength(2); + await expect( + readProjectsRegistry({ registryPath, requireAutostartTrust: true }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await expect( + readProjectsRegistryStrict({ registryPath, requireAutostartTrust: true }) + ).rejects.toThrow("regular file owned by the current user with mode 0600"); + } +}); + +test("autostart rejects a symlink without authorizing its private target", async () => { + const target = join(root, "target.json"); + await rename(registryPath, target); + await symlink(target, registryPath); + expect((await readProjectsRegistry({ registryPath })).projects).toHaveLength( + 2 + ); + await expect( + readProjectsRegistry({ registryPath, requireAutostartTrust: true }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await expect( + claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect((await stat(target)).mode & 0o777).toBe(0o600); + expect( + (await readProjectsRegistry({ registryPath: target })).projects[0] + ?.autostart + ).toBeUndefined(); +}); + +test("every autostart mutation rechecks trust after a previously trusted read", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + await readProjectsRegistry({ registryPath, requireAutostartTrust: true }); + await chmod(registryPath, 0o666); + const before = await readFile(registryPath, "utf8"); + const operations = [ + () => setProjectAutostart({ id: "alpha", registryPath }), + () => removeProjectAutostart({ id: "alpha", registryPath }), + () => + setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }), + () => + claimProjectAutostart({ id: "alpha", bootId: "boot-b", registryPath }), + () => + recordProjectAutostartResult({ + id: "alpha", + bootId: "boot-a", + registryPath, + result: { + status: "started" as const, + reason: null, + at: "2026-01-01T00:00:00.000Z", + }, + }), + ]; + for (const operation of operations) { + await expect(operation()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(await readFile(registryPath, "utf8")).toBe(before); + } +}); + +test("trusted reads reject nonregular and oversized files with value-free errors", async () => { + await expect( + readProjectsRegistry({ registryPath: root, requireAutostartTrust: true }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await truncate(registryPath, 16 * 1024 * 1024 + 1); + try { + await readProjectsRegistry({ registryPath, requireAutostartTrust: true }); + throw new Error("Expected trust refusal"); + } catch (error: unknown) { + expect(error).toMatchObject({ code: "E_PERMISSION" }); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain("EACCES"); + } +}); + +test("owner validation rejects a foreign UID without changing real file ownership", () => { + const owned = { + regular: true, + fileUid: 1001, + currentUid: 1001, + mode: 0o600, + size: 128, + }; + expect(isTrustedAutostartRegistryFile(owned)).toBe(true); + expect(isTrustedAutostartRegistryFile({ ...owned, fileUid: 1002 })).toBe( + false + ); + expect(isTrustedAutostartRegistryFile({ ...owned, regular: false })).toBe( + false + ); + expect( + isTrustedAutostartRegistryFile({ ...owned, currentUid: undefined }) + ).toBe(true); +}); + +test("trusted mutations publish owner-private registry files", async () => { + await chmod(registryPath, 0o644); + await setProjectAutostart({ id: "alpha", registryPath }); + expect((await stat(registryPath)).mode & 0o777).toBe(0o600); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + expect((await stat(registryPath)).mode & 0o777).toBe(0o600); +}); + +async function createTrustProject() { + const projectDir = join(root, "alpha", ".hack"); + await mkdir(projectDir, { recursive: true }); + const configFile = join(projectDir, "hack.config.json"); + const composeFile = join(projectDir, "docker-compose.yml"); + const envFile = join(projectDir, ".env"); + await writeFile(configFile, JSON.stringify({ name: "alpha" })); + await writeFile(composeFile, "services: {}\n"); + await writeFile(envFile, ""); + return { + projectRoot: join(root, "alpha"), + projectDir, + projectDirName: ".hack" as const, + composeFile, + envFile, + configFile, + }; +} + +test("ordinary upsert, touch and prune cannot launder writable autostart intent", async () => { + const project = await createTrustProject(); + await setProjectAutostart({ id: "alpha", registryPath }); + await chmod(registryPath, 0o664); + const before = await readFile(registryPath, "utf8"); + await expect(upsertProjectRegistration({ project })).rejects.toMatchObject({ + code: "E_PERMISSION", + }); + expect(await touchProjectRegistration({ project })).toBeNull(); + await expect(removeProjectsById({ ids: ["beta"] })).rejects.toMatchObject({ + code: "E_PERMISSION", + }); + expect(await readFile(registryPath, "utf8")).toBe(before); + expect((await stat(registryPath)).mode & 0o777).toBe(0o664); +}); + +test("ordinary writes retain trusted intent and can upgrade unmarked legacy permissions", async () => { + const project = await createTrustProject(); + await chmod(registryPath, 0o664); + expect((await upsertProjectRegistration({ project })).status).toBe("updated"); + expect((await stat(registryPath)).mode & 0o777).toBe(0o600); + await setProjectAutostart({ id: "alpha", branch: "preview", registryPath }); + await upsertProjectRegistration({ + project, + nowIso: "2027-01-01T00:00:00.000Z", + }); + expect((await read())?.autostart?.branch).toBe("preview"); + expect((await stat(registryPath)).mode & 0o777).toBe(0o600); +}); From e191c62dc2d13ee7514f362acefe88e81f092c55 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:52:46 +0000 Subject: [PATCH 04/44] feat(autostart): expose desktop policy and login progress contract --- docs/always-on-projects.md | 112 ++++++--- docs/diagrams/autostart-login.mmd | 8 +- docs/diagrams/autostart-login.svg | 4 +- docs/reference/cli.md | 8 + src/commands/autostart.ts | 179 +++++++++++--- src/commands/projects.ts | 88 ++++++- src/daemon/autostart.ts | 264 +++++++++++++++++---- src/daemon/runtime-cache.ts | 30 ++- src/lib/autostart-view.ts | 151 ++++++++++++ src/lib/native-authored-command.ts | 2 +- src/lib/native-compose-command.ts | 2 + src/lib/project-views.ts | 30 +-- src/lib/projects-registry.ts | 363 ++++++++++++++++++++++++++--- tests/autostart-cli.test.ts | 155 ++++++++++-- tests/autostart-view.test.ts | 134 +++++++++++ tests/daemon-autostart.test.ts | 244 +++++++++++++++++++ tests/project-autostart.test.ts | 323 ++++++++++++++++++++++++- tests/runtime-cache.test.ts | 105 +++++++++ tests/status-native.test.ts | 37 ++- 19 files changed, 2053 insertions(+), 186 deletions(-) create mode 100644 src/lib/autostart-view.ts create mode 100644 tests/autostart-view.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index b542be6b8..25cb8d78c 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -5,8 +5,8 @@ Hack's per-user login service, then mark a registered project: ```sh hack daemon install -hack autostart add sample -hack autostart add sample --branch preview +hack autostart add --project sample +hack autostart add --project sample --branch preview hack autostart list --json ``` @@ -21,13 +21,20 @@ changes the marker; it does not start anything immediately. stop it. A later `hack up` clears the pause. Pausing does not stop running services: ```sh -hack autostart pause sample -hack autostart resume sample -hack autostart pause # all marked projects -hack autostart resume # all marked projects -hack autostart remove sample # forget the marker; leave running services alone +hack autostart pause --project sample +hack autostart resume --project sample +hack autostart pause # globally pause automatic starts +hack autostart resume # resume the global startup policy +hack autostart remove --project sample # forget the marker; leave services alone ``` +The positional project spelling remains accepted. All commands accept +`--json --no-interactive`; mutations return the usual `{ok,data}` or +`{ok:false,error}` envelope. `list --json` returns a bare object. +Global pause also covers projects marked afterwards. Global resume preserves +projects paused by a manual `down`; start one with `hack up` or explicitly +`hack autostart resume --project sample` to clear that manual pause. + ## At login ![Login startup sequence](diagrams/autostart-login.svg) @@ -47,7 +54,11 @@ runtime state is skipped rather than taken as proof that the instance is down. Adoption requires a running container. An exited-zero container is treated as a completed job only when its service is a dependency target explicitly declared with `condition: service_completed_successfully`; other stopped services require -`up`. This slice reads that declaration from the primary Compose file. +`up`. Adoption also requires every service in the selected default-profile model +to have a container, including required dependency targets. A missing service +runs the normal `up` path. This slice reads the primary Compose file; branch +overrides, explicit Compose file/profile overrides and unsupported composition +cannot prove adoption, so they run ordinary `up` and may repeat startup hooks. No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a @@ -56,8 +67,14 @@ not install routing, start Docker, sign in, or migrate configuration at login. Before trusting intent, Hack opens `projects.json` without following links and requires a regular file owned by the current user, with no group or other write access. That check repeats at every autostart read and mutation. Unsafe registry -permissions refuse startup; inspect ownership and set the registry mode to `0600` -before retrying. Ordinary project listings remain readable for diagnosis. +permissions refuse startup. Do not repair a formerly writable registry with +in-place `chmod`: another user's already-open writer descriptor remains writable. +Preserve the old file outside the active registry, start with a fresh registry, +register the projects again, and re-enable only reviewed projects. Do not copy +untrusted markers into the replacement. Hack publishes fresh registry files with +mode `0600`. Ordinary project listings remain readable for diagnosis. +The trust check observes current file ownership and mode; it cannot recover the +history of arbitrary external permission changes or revoke an existing writer. A claim is saved before each attempt, once per OS boot. Restarting the daemon in the same boot does not repeat starts. A crash after the claim can leave an @@ -79,28 +96,71 @@ No system service or `sudo` is needed for Hack's login service. ## JSON contract -`hack projects --json` and `hack status --json` add `autostart` to every project, -including summary views. It is `null` for unmarked projects. `hack autostart list ---json` returns `{ "projects": [...] }` containing only marked registrations; each -entry has `project_id`, `name`, and the same `autostart` object: +`hack projects --json`, `hack status --json`, and `/v1/projects` include an +`autostart` object for every project, including summary views and unmarked +projects. `hack autostart list --json` returns marked registrations. All these +payloads include the same top-level `autostart_global` object: ```json { - "project_id": "example-id", - "name": "sample", - "autostart": { - "branch": null, - "paused": false, - "last_result": { - "status": "skipped", - "reason": "engine_timeout", - "at": "2026-01-01T00:00:00.000Z" + "projects": [{ + "project_id": "example-id", + "name": "sample", + "autostart": { + "enabled": true, + "state": "on", + "paused_reason": null, + "failures": { + "count": 0, + "last_at": null, + "failed_services": [], + "last_error_code": null + }, + "max_failures": 3, + "branch": null, + "paused": false, + "last_result": null } + }], + "autostart_global": { + "paused": false, + "login_run": { + "in_progress": false, + "started_at": null, + "pending": [], + "started": [], + "failed": [] + }, + "service_ready": false } } ``` -`branch` is `null` for base or a branch-instance slug. `paused` is a boolean. +`enabled` is false and `state` is `off` for an unmarked project. State is `on` +for an enabled project, `paused` after a manual stop or global pause, or `starting` while the +project is pending in an active login run. Invalid markers use `needs_attention`. +`paused_reason` is `manual_stop` for an individual pause and otherwise `null`. +`failures` has the empty values above in this first slice; `max_failures: 3` +reserves the keep-up retry bound, but no retries run in this slice. Explicit +project resume clears the pause and failure state. The keep-up slice will use +the same fields for failure counts and `needs_attention` after exhausting retries. + +Global `paused` is an independent machine setting. Login-run arrays contain +stable project IDs: pending includes queued and active attempts, started includes +started and already-running projects, and failed includes real startup failures +and refusals such as an engine timeout or missing key. Manual/global pauses, +cancellation and target changes leave the queue without being marked failed. +An interrupted run is closed before the next daemon startup; it does +not authorize repeating a claimed start. `started_at` is `null` before the first +run or its ISO 8601 start time. `service_ready` means a login-service file is +installed for this invocation; it does not prove manager readiness, unit +freshness, or a real login run. A launcher that disables autostart reports false. +After a daemon crash, persisted progress can remain `starting` until the daemon +starts again and closes the interrupted run. Invalid global policy fails closed +as globally paused; an explicit global resume repairs it. + +The additive `branch` field is `null` for base or a branch-instance slug. +`paused` reflects the individual marker, separate from global pause. `last_result` is `null` before any recorded attempt, otherwise its `status` is `started`, `already_running`, `skipped`, or `failed`; `reason` is `null` on success or a machine-readable code; `at` is an ISO 8601 timestamp. Reasons never contain @@ -112,10 +172,10 @@ Current skip reasons are `engine_timeout`, `routing_failed`, `boot_identity_unavailable`, `native_unsupported`, `runtime_unknown`, `interactive_required`, `env_key_missing`, `keychain_unavailable`, `pending_env_migration`, `project_unavailable`, `paused`, `target_changed`, -`daemon_stopped`, and `invalid_marker`. Failure reasons are `start_failed`, +`daemon_stopped`, `global_paused`, and `invalid_marker`. Failure reasons are `start_failed`, `start_timeout`, and `output_limit`. -An invalid or newer marker is shown as paused with `skipped/invalid_marker` and +An invalid or newer marker is shown as `needs_attention` with `skipped/invalid_marker` and the sentinel timestamp `1970-01-01T00:00:00.000Z` (no attempt occurred). Other project commands keep working and unrelated registry writes preserve the original marker. Remove and re-add it with `hack autostart remove sample` then diff --git a/docs/diagrams/autostart-login.mmd b/docs/diagrams/autostart-login.mmd index 3111ea316..642757a83 100644 --- a/docs/diagrams/autostart-login.mmd +++ b/docs/diagrams/autostart-login.mmd @@ -10,6 +10,10 @@ sequenceDiagram participant CLI Login->>Daemon: Start user service Daemon->>Registry: Read machine markers + alt Globally paused + Daemon->>Daemon: Skip automatic starts + else Automatic starts enabled + Daemon->>Registry: Begin login progress Daemon->>Engine: Wait up to 60 seconds opt Routing already installed Daemon->>CLI: global up, no interaction @@ -23,5 +27,7 @@ sequenceDiagram else Running or unknown Daemon->>Daemon: Adopt or skip end - Daemon->>Registry: Save result and reason + Daemon->>Registry: Save result and progress + end + Daemon->>Registry: Finish login progress end diff --git a/docs/diagrams/autostart-login.svg b/docs/diagrams/autostart-login.svg index e628efb13..8e0d7225f 100644 --- a/docs/diagrams/autostart-login.svg +++ b/docs/diagrams/autostart-login.svg @@ -1,4 +1,4 @@ -Autostart at loginopt[Routing already installed][Running or unknown]alt[Confirmed stopped]loop[Unpaused projects in stable order]12345678910Start user serviceRead machine markersWait up to 60 secondsglobal up, no interactionClaim once per bootInspect selected instanceup -d, no interactionResult without promptsAdopt or skipSave result and reasonLoginDaemonEngineRegistryCLILoginDaemonEngineRegistryCLI \ No newline at end of file +]]>Autostart at loginopt[Routing already installed][Running or unknown]alt[Confirmed stopped]loop[Unpaused projects in stable order][Automatic starts enabled]alt[Globally paused]12345678910111213Start user serviceRead machine markersSkip automatic startsBegin login progressWait up to 60 secondsglobal up, no interactionClaim once per bootInspect selected instanceup -d, no interactionResult without promptsAdopt or skipSave result and progressFinish login progressLoginDaemonEngineRegistryCLILoginDaemonEngineRegistryCLI \ No newline at end of file diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0309d326b..4b4a53a94 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -57,6 +57,8 @@ hack autostart add [project] [options] | Option | Description | | --- | --- | | `--branch ` | Run against a branch-specific instance (compose name + hostnames) | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -81,6 +83,8 @@ hack autostart remove [project] [options] | Option | Description | | --- | --- | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -124,6 +128,8 @@ hack autostart pause [project] [options] | Option | Description | | --- | --- | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | @@ -148,6 +154,8 @@ hack autostart resume [project] [options] | Option | Description | | --- | --- | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--json` | Output JSON (machine-readable) | | `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | | `--help, -h` | Show help | | `--version, -v` | Show version | diff --git a/src/commands/autostart.ts b/src/commands/autostart.ts index e0a12fc99..05e7dc715 100644 --- a/src/commands/autostart.ts +++ b/src/commands/autostart.ts @@ -6,20 +6,27 @@ import { defineCommand, withHandler, } from "../cli/command.ts"; -import { optBranch, optJson } from "../cli/options.ts"; +import { optBranch, optJson, optProject } from "../cli/options.ts"; import { parseProjectSelectorOption, unknownProjectMessage, } from "../cli/project-selector.ts"; +import { + projectAutostartFromGlobal, + readAutostartServiceReady, + serializeAutostartGlobal, +} from "../lib/autostart-view.ts"; +import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; import { sanitizeBranchSlug } from "../lib/project.ts"; import { PROJECT_SELECTOR_PATTERN } from "../lib/project-name.ts"; -import { serializeProjectAutostart } from "../lib/project-views.ts"; import { + getDefaultAutostartGlobal, type RegisteredProject, readProjectsRegistry, removeProjectAutostart, selectRegisteredProjectByName, + setAutostartGlobalPaused, setProjectAutostart, setProjectAutostartPaused, } from "../lib/projects-registry.ts"; @@ -30,7 +37,7 @@ const addSpec = defineCommand({ name: "add", summary: "Start this registered project at login on this machine", group: "Project", - options: [optBranch], + options: [optBranch, optProject, optJson], positionals: projectPositionals, subcommands: [], } as const); @@ -38,7 +45,7 @@ const removeSpec = defineCommand({ name: "remove", summary: "Remove this project's machine autostart marker", group: "Project", - options: [], + options: [optProject, optJson], positionals: projectPositionals, subcommands: [], } as const); @@ -46,7 +53,7 @@ const pauseSpec = defineCommand({ name: "pause", summary: "Pause one marked project, or all marked projects", group: "Project", - options: [], + options: [optProject, optJson], positionals: projectPositionals, subcommands: [], } as const); @@ -54,7 +61,7 @@ const resumeSpec = defineCommand({ name: "resume", summary: "Resume one marked project, or all marked projects", group: "Project", - options: [], + options: [optProject, optJson], positionals: projectPositionals, subcommands: [], } as const); @@ -67,6 +74,47 @@ const listSpec = defineCommand({ subcommands: [], } as const); +function selectedProjectName(opts: { + readonly positional?: string; + readonly option?: string; +}): string | undefined { + if (opts.positional !== undefined && opts.option !== undefined) { + throw new CliUsageError( + "Use either a project argument or --project, not both." + ); + } + return opts.option ?? opts.positional; +} +async function writeMutation(opts: { + readonly json: boolean; + readonly action: string; + readonly projects: readonly RegisteredProject[]; + readonly message: string; +}): Promise { + if (opts.json) { + const global = serializeAutostartGlobal( + (await readProjectsRegistry()).autostartGlobal ?? + getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); + emitCliResult({ + result: okResult({ + data: { + action: opts.action, + autostart_global: global, + projects: opts.projects.map((project) => ({ + project_id: project.id, + name: project.name, + autostart: projectAutostartFromGlobal({ project, global }), + })), + }, + }), + }); + return; + } + process.stdout.write(`${opts.message}\n`); +} + /** Registry selection changes only the machine marker, without reading project configuration. */ async function selectProject(opts: { readonly ctx: CliContext; @@ -117,7 +165,13 @@ async function selectProject(opts: { } const add: CommandHandlerFor = async ({ ctx, args }) => { - const project = await selectProject({ ctx, name: args.positionals.project }); + const project = await selectProject({ + ctx, + name: selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }), + }); const [cwdLinked, registeredLinked] = await Promise.all([ isLinkedGitWorktree({ repoRoot: ctx.cwd }), isLinkedGitWorktree({ repoRoot: project.repoRoot }), @@ -139,36 +193,96 @@ const add: CommandHandlerFor = async ({ ctx, args }) => { "Invalid --branch value; use a branch alias containing letters or digits." ); } - await setProjectAutostart({ id: project.id, branch }); - process.stdout.write( - "Autostart enabled on this machine. Install the login service with: hack daemon install\n" - ); + const updated = await setProjectAutostart({ id: project.id, branch }); + await writeMutation({ + json: args.options.json, + action: "add", + projects: [updated], + message: + "Autostart enabled on this machine. Install the login service with: hack daemon install", + }); return 0; }; const remove: CommandHandlerFor = async ({ ctx, args }) => { - const project = await selectProject({ ctx, name: args.positionals.project }); - await removeProjectAutostart({ id: project.id }); - process.stdout.write("Autostart removed on this machine.\n"); + const project = await selectProject({ + ctx, + name: selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }), + }); + const updated = await removeProjectAutostart({ id: project.id }); + await writeMutation({ + json: args.options.json, + action: "remove", + projects: updated ? [updated] : [], + message: "Autostart removed on this machine.", + }); return 0; }; const pause: CommandHandlerFor = async ({ ctx, args }) => { const project = - args.positionals.project === undefined + selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }) === undefined ? null - : await selectProject({ ctx, name: args.positionals.project }); - await setProjectAutostartPaused({ id: project?.id, paused: true }); - process.stdout.write("Autostart paused; running projects are unchanged.\n"); + : await selectProject({ + ctx, + name: selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }), + }); + let updated: readonly RegisteredProject[]; + if (project) { + updated = await setProjectAutostartPaused({ id: project.id, paused: true }); + } else { + await setAutostartGlobalPaused({ paused: true }); + updated = (await readProjectsRegistry()).projects.filter( + (entry) => entry.autostart + ); + } + await writeMutation({ + json: args.options.json, + action: "pause", + projects: updated, + message: "Autostart paused; running projects are unchanged.", + }); return 0; }; const resume: CommandHandlerFor = async ({ ctx, args }) => { const project = - args.positionals.project === undefined + selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }) === undefined ? null - : await selectProject({ ctx, name: args.positionals.project }); - await setProjectAutostartPaused({ id: project?.id, paused: false }); - process.stdout.write( - "Autostart resumed; use 'hack up -d' to start a project now.\n" - ); + : await selectProject({ + ctx, + name: selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }), + }); + let updated: readonly RegisteredProject[]; + if (project) { + updated = await setProjectAutostartPaused({ + id: project.id, + paused: false, + }); + } else { + await setAutostartGlobalPaused({ paused: false }); + updated = (await readProjectsRegistry()).projects.filter( + (entry) => entry.autostart + ); + } + await writeMutation({ + json: args.options.json, + action: "resume", + projects: updated, + message: "Autostart resumed; use 'hack up -d' to start a project now.", + }); return 0; }; const list: CommandHandlerFor = async ({ args }) => { @@ -176,18 +290,31 @@ const list: CommandHandlerFor = async ({ args }) => { const projects = registry.projects .filter((project) => project.autostart) .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); if (args.options.json) { process.stdout.write( - `${JSON.stringify({ projects: projects.map((project) => ({ project_id: project.id, name: project.name, autostart: serializeProjectAutostart(project.autostart) })) })}\n` + `${JSON.stringify({ autostart_global: global, projects: projects.map((project) => ({ project_id: project.id, name: project.name, autostart: projectAutostartFromGlobal({ project, global }) })) })}\n` ); return 0; } + if (global.paused) { + await display.panel({ + title: "Always-on", + tone: "warn", + lines: [ + "Always-on is paused for all projects. Resume with: hack autostart resume", + ], + }); + } await display.table({ columns: ["Project", "Branch", "Autostart", "Last result", "Reason"], rows: projects.map((project) => [ project.name, project.autostart?.branch ?? "base", - project.autostart?.paused ? "paused" : "always-on", + projectAutostartFromGlobal({ project, global }).state, project.autostart?.lastResult?.status ?? "not attempted", project.autostart?.lastResult?.reason ?? "", ]), diff --git a/src/commands/projects.ts b/src/commands/projects.ts index e74943440..d28402d33 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -19,6 +19,12 @@ import { } from "../cli/project-selector.ts"; import { PROJECT_COMPOSE_FILENAME } from "../constants.ts"; import { requestDaemonJson } from "../daemon/client.ts"; +import { + type AutostartGlobalView, + projectAutostartFromGlobal, + readAutostartServiceReady, + serializeAutostartGlobal, +} from "../lib/autostart-view.ts"; import { resolveEffectiveBranch } from "../lib/branches.ts"; import { readInternalExtraHostsIp, @@ -59,6 +65,7 @@ import { } from "../lib/project-views.ts"; import { findDeadProjectRegistrations, + getDefaultAutostartGlobal, type RegisteredProject, readProjectsRegistry, removeProjectsById, @@ -499,6 +506,7 @@ async function runNativeStatus(opts: { readonly ps?: typeof nativeProjectPs; readonly authored?: typeof tryNativeComposeCommand; readonly readRegistry?: typeof readProjectsRegistry; + readonly serviceReady?: typeof readAutostartServiceReady; }): Promise { // Once, whichever native observer answers (HACK-2026). noteNativeBackendBeta({ json: opts.json }); @@ -529,7 +537,17 @@ async function runNativeStatus(opts: { name: parseProjectSelectorOption({ projectOpt: opts.projectOpt }) .name, }); - return { autostart: serializeProjectAutostart(registration?.autostart) }; + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await (opts.serviceReady ?? readAutostartServiceReady)() + ); + return { + autostart: projectAutostartFromGlobal({ + project: registration ?? undefined, + global, + }), + autostart_global: global, + }; }, }); if (authored !== null) { @@ -571,9 +589,20 @@ async function runNativeStatus(opts: { entry.projectDir === project.projectDir || entry.worktrees?.some((worktree) => worktree.path === project.projectRoot) ); - const autostart = serializeProjectAutostart(registration?.autostart); + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await (opts.serviceReady ?? readAutostartServiceReady)() + ); + const autostart = projectAutostartFromGlobal({ + project: registration, + global, + }); if (opts.json) { - emitCliResult({ result: okResult({ data: { ...result, autostart } }) }); + emitCliResult({ + result: okResult({ + data: { ...result, autostart, autostart_global: global }, + }), + }); } else { await displayNativePs(result, false); if (registration?.autostart) { @@ -652,8 +681,13 @@ async function runProjects(opts: { buildMetaByProjectName({ views }) ) : new Map(); + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); if (opts.json) { outputProjectsJson({ + autostartGlobal: global, summary: opts.summary === true, profiler, started, @@ -684,6 +718,15 @@ async function runProjects(opts: { return 0; } + if (global.paused) { + await display.panel({ + title: "Always-on", + tone: "warn", + lines: [ + "Always-on is paused for all projects. Resume with: hack autostart resume", + ], + }); + } await display.section("Projects"); await display.table({ columns: [ @@ -703,7 +746,13 @@ async function runProjects(opts: { runningCount, runtimeOk: runtime.ok, }); - const autostartState = p.autostart?.paused ? "paused" : "always-on"; + const autostartState = serializeProjectAutostart(p.autostart, { + globallyPaused: global.paused, + starting: + global.login_run.in_progress && + p.projectId !== undefined && + global.login_run.pending.includes(p.projectId), + }).state; return [ p.name, p.status, @@ -793,6 +842,10 @@ async function outputDaemonProjects({ const payload = overlayAutostartPolicy({ cached, projects: registry.projects, + global: serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ), }); writeProfiledProjects({ payload, @@ -811,22 +864,28 @@ async function outputDaemonProjects({ function overlayAutostartPolicy(opts: { readonly cached: Readonly>; readonly projects: readonly RegisteredProject[]; + readonly global?: AutostartGlobalView; }): Record { const markers = new Map( - opts.projects.map((project) => [project.id, project.autostart]) + opts.projects.map((project) => [project.id, project]) ); + const global = + opts.global ?? serializeAutostartGlobal(getDefaultAutostartGlobal(), false); return { ...opts.cached, + autostart_global: global, projects: Array.isArray(opts.cached.projects) ? opts.cached.projects.map((project: unknown) => isRecord(project) ? { ...project, - autostart: serializeProjectAutostart( - typeof project.project_id === "string" - ? markers.get(project.project_id) - : undefined - ), + autostart: projectAutostartFromGlobal({ + project: + typeof project.project_id === "string" + ? markers.get(project.project_id) + : undefined, + global, + }), } : project ) @@ -849,6 +908,7 @@ type RuntimeRecoveryNotice = { }; function outputProjectsJson(opts: { + readonly autostartGlobal: AutostartGlobalView; readonly summary: boolean; readonly profiler: OperationTimings; readonly started: number; @@ -866,6 +926,7 @@ function outputProjectsJson(opts: { }): void { const runtimeMeta = formatRuntimeMeta({ runtime: opts.runtime }); const payload = { + autostart_global: opts.autostartGlobal, ...(opts.summary ? { detail_level: "summary" } : {}), generated_at: new Date().toISOString(), filter: opts.filterLabel, @@ -890,6 +951,13 @@ function outputProjectsJson(opts: { ...(opts.summary ? serializeProjectSummary(view) : serializeProjectView(view)), + autostart: serializeProjectAutostart(view.autostart, { + globallyPaused: opts.autostartGlobal.paused, + starting: + opts.autostartGlobal.login_run.in_progress && + view.projectId !== undefined && + opts.autostartGlobal.login_run.pending.includes(view.projectId), + }), ...(opts.includeMeta ? { meta: opts.metaByName.get(view.name) ?? null } : {}), diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index da88e4996..8e78970b2 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -13,9 +13,13 @@ import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import { findProjectContextAtRoot, readProjectConfig } from "../lib/project.ts"; import { + beginAutostartLoginRun, claimProjectAutostart, + finishAutostartLoginRun, type RegisteredProject, + readAutostartGlobal, readProjectsRegistry, + recordAutostartLoginOutcome, recordProjectAutostartResult, } from "../lib/projects-registry.ts"; @@ -66,36 +70,79 @@ export async function runAutostartAtLogin( } = {} ): Promise { const deps = opts.dependencies ?? productionDependencies(); + const prior = await readAutostartGlobal({ registryPath: opts.registryPath }); + if (prior.loginRun.inProgress && prior.loginRun.runId) { + await finishAutostartLoginRun({ + runId: prior.loginRun.runId, + registryPath: opts.registryPath, + }); + } const registry = await readProjectsRegistry({ registryPath: opts.registryPath, requireAutostartTrust: true, }); - const candidates = registry.projects - .filter((project) => project.autostart && !project.autostart.paused) - .sort( - (a, b) => - a.name.localeCompare(b.name, "en") || a.id.localeCompare(b.id, "en") - ); - if (candidates.length === 0) { + if (registry.autostartGlobal?.paused) { return; } const machineBootId = await deps.bootId().catch(() => null); const bootId = machineBootId ?? "unavailable"; - const pending = candidates.filter( - (project) => project.autostart?.lastAttemptBootId !== bootId - ); - if (pending.length === 0) { + const pending = registry.projects + .filter( + (project) => + project.autostart && + !project.autostart.paused && + !project.autostart.invalid && + project.autostart.lastAttemptBootId !== bootId + ) + .sort( + (a, b) => + a.name.localeCompare(b.name, "en") || a.id.localeCompare(b.id, "en") + ); + if (pending.length === 0 || opts.signal?.aborted) { return; } - const refusal = await prepareAutostartRuntime({ - deps, - machineBootId, - signal: opts.signal, + const loginRun = await beginAutostartLoginRun({ + bootId, + startedAt: new Date(deps.now()).toISOString(), + pendingIds: pending.map((project) => project.id), + registryPath: opts.registryPath, }); - if (opts.signal?.aborted) { + if (!loginRun?.runId) { return; } + try { + const refusal = await prepareAutostartRuntime({ + deps, + machineBootId, + signal: opts.signal, + registryPath: opts.registryPath, + }); + await dispatchAutostartProjects({ + ...opts, + pending, + deps, + bootId, + runId: loginRun.runId, + refusal, + }); + } finally { + await finishAutostartLoginRun({ + runId: loginRun.runId, + registryPath: opts.registryPath, + }); + } +} +async function dispatchAutostartProjects(opts: { + readonly pending: readonly RegisteredProject[]; + readonly deps: AutostartDependencies; + readonly bootId: string; + readonly runId: string; + readonly refusal: string | null; + readonly registryPath?: string; + readonly signal?: AbortSignal; +}): Promise { + const { deps, bootId, pending } = opts; const active = new Set>(); const tasks: Promise[] = []; let lastStartAt: number | null = null; @@ -113,7 +160,10 @@ export async function runAutostartAtLogin( await deps.sleep(delay, opts.signal); } } - if (opts.signal?.aborted) { + if ( + opts.signal?.aborted || + (await readAutostartGlobal({ registryPath: opts.registryPath })).paused + ) { break; } const project = await claimProjectAutostart({ @@ -131,12 +181,9 @@ export async function runAutostartAtLogin( dispatched = done; }); const task = startClaimedProject({ + ...opts, project, - bootId, - refusal, - deps, onDispatched: dispatched, - ...opts, }); const tracked = task.then( () => { @@ -159,6 +206,7 @@ export async function runAutostartAtLogin( async function prepareAutostartRuntime(opts: { readonly deps: AutostartDependencies; readonly machineBootId: string | null; + readonly registryPath?: string; readonly signal?: AbortSignal; }): Promise { const refusal = opts.deps.startupRefusal?.(); @@ -172,6 +220,11 @@ async function prepareAutostartRuntime(opts: { const deadline = deps.now() + ENGINE_WAIT_MS; let ready = false; while (!signal?.aborted) { + if ( + (await readAutostartGlobal({ registryPath: opts.registryPath })).paused + ) { + return "global_paused"; + } ready = await deps.engineReady().catch(() => false); if (ready || deps.now() >= deadline) { break; @@ -182,10 +235,21 @@ async function prepareAutostartRuntime(opts: { return "engine_timeout"; } if ( - (await deps.routingInstalled()) && - !(await deps.startRouting(signal).catch(() => false)) + signal?.aborted || + (await readAutostartGlobal({ registryPath: opts.registryPath })).paused ) { - return "routing_failed"; + return "global_paused"; + } + if (await deps.routingInstalled()) { + if ( + signal?.aborted || + (await readAutostartGlobal({ registryPath: opts.registryPath })).paused + ) { + return "global_paused"; + } + if (!(await deps.startRouting(signal).catch(() => false))) { + return "routing_failed"; + } } return null; } @@ -193,6 +257,7 @@ async function prepareAutostartRuntime(opts: { async function startClaimedProject(opts: { readonly project: RegisteredProject; readonly bootId: string; + readonly runId: string; readonly refusal: string | null; readonly onDispatched: () => void; readonly deps: AutostartDependencies; @@ -211,6 +276,7 @@ async function startClaimedProject(opts: { if ( !current?.autostart || current.autostart.paused || + registry.autostartGlobal?.paused || opts.signal?.aborted ) { result = { @@ -238,6 +304,18 @@ async function startClaimedProject(opts: { result: { ...result, at: new Date(opts.deps.now()).toISOString() }, registryPath: opts.registryPath, }); + await recordAutostartLoginOutcome({ + runId: opts.runId, + id: opts.project.id, + succeeded: + result.status === "started" || result.status === "already_running", + cancelled: + result.reason !== null && + ["paused", "global_paused", "daemon_stopped", "target_changed"].includes( + result.reason + ), + registryPath: opts.registryPath, + }); } async function dispatchIfStillWanted(opts: { @@ -247,12 +325,16 @@ async function dispatchIfStillWanted(opts: { readonly signal?: AbortSignal; readonly registryPath?: string; }): Promise { - const latest = ( - await readProjectsRegistry({ - registryPath: opts.registryPath, - requireAutostartTrust: true, - }) - ).projects.find((project) => project.id === opts.project.id); + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + requireAutostartTrust: true, + }); + const latest = registry.projects.find( + (project) => project.id === opts.project.id + ); + if (registry.autostartGlobal?.paused) { + return { status: "skipped", reason: "global_paused" }; + } if (!latest?.autostart || latest.autostart.paused || opts.signal?.aborted) { return { status: "skipped", @@ -417,6 +499,18 @@ async function inspectComposeProject( if (cfg.parseError) { return "unknown"; } + // An uncertain selected graph must run ordinary up rather than adopt a subset. + if ( + project.autostart?.branch || + process.env.COMPOSE_FILE || + process.env.COMPOSE_PROFILES + ) { + return "stopped"; + } + const model = await readAutostartComposeModel(context.composeFile); + if (!model) { + return "stopped"; + } const name = await resolveComposeProjectName({ project: context, cfg }); const composeName = project.autostart?.branch ? `${name}--${project.autostart.branch}` @@ -455,22 +549,25 @@ async function inspectComposeProject( if (inspected.exitCode !== 0) { return "unknown"; } - const completedServices = await readCompletedDependencyServices( - context.composeFile + return classifyAutostartContainers( + inspected.stdout, + model.completedServices, + model.requiredServices ); - return classifyAutostartContainers(inspected.stdout, completedServices); } /** Only declared successful-completion dependency targets may be adopted after exit. */ export function classifyAutostartContainers( text: string, - completedServices: ReadonlySet = new Set() + completedServices: ReadonlySet = new Set(), + requiredServices: ReadonlySet = new Set() ): "running" | "stopped" | "unknown" { const states = text.trim().split("\n").filter(Boolean); if (states.length === 0) { return "unknown"; } let running = false; + const observed = new Set(); for (const state of states) { const [service, status, exitCode, extra] = state.split("|"); if (extra !== undefined || !service || !status || !exitCode) { @@ -486,23 +583,112 @@ export function classifyAutostartContainers( ) { return "stopped"; } + observed.add(service); running ||= status === "running"; } + if ([...requiredServices].some((service) => !observed.has(service))) { + return "stopped"; + } return running ? "running" : "stopped"; } -async function readCompletedDependencyServices( +type AutostartComposeModel = { + readonly requiredServices: ReadonlySet; + readonly completedServices: ReadonlySet; +}; + +async function readAutostartComposeModel( composeFile: string -): Promise> { +): Promise { const text = await readTextFile(composeFile); if (text === null) { - return new Set(); + return null; } try { - return completedDependencyServices(YAML.parse(text)); + return autostartComposeModel(YAML.parse(text)); } catch { - return new Set(); + return null; + } +} + +/** Conservative default-profile model; unsupported composition is never adoption evidence. */ +export function autostartComposeModel( + compose: unknown +): AutostartComposeModel | null { + if ( + !(isRecord(compose) && isRecord(compose.services)) || + compose.include !== undefined + ) { + return null; + } + const services = compose.services; + const requiredServices = new Set(); + for (const [name, service] of Object.entries(services)) { + const defaultService = isDefaultAutostartService(service); + if (defaultService === null) { + return null; + } + if (defaultService) { + requiredServices.add(name); + } + } + for (const name of requiredServices) { + const service = services[name]; + if (!isRecord(service)) { + return null; + } + const dependencies = requiredComposeDependencies(service.depends_on); + if (dependencies === null) { + return null; + } + for (const target of dependencies) { + if (!isRecord(services[target])) { + return null; + } + requiredServices.add(target); + } + } + return { + requiredServices, + completedServices: completedDependencyServices(compose), + }; +} + +function isDefaultAutostartService(service: unknown): boolean | null { + if (!isRecord(service) || service.extends !== undefined) { + return null; + } + if (service.profiles === undefined) { + return true; + } + if ( + !( + Array.isArray(service.profiles) && + service.profiles.every( + (profile) => typeof profile === "string" && !profile.includes("$") + ) + ) + ) { + return null; + } + return service.profiles.length === 0; +} + +function requiredComposeDependencies(value: unknown): readonly string[] | null { + if (value === undefined) { + return []; + } + if (Array.isArray(value)) { + return value.every((item) => typeof item === "string") ? value : null; + } + if (!isRecord(value)) { + return null; } + return Object.entries(value) + .filter( + ([, dependency]) => !isRecord(dependency) || dependency.required !== false + ) + .map(([name]) => name); } /** A zero exit or restart policy alone does not establish a one-shot service. */ diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index d8cb76dac..153f89547 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -1,5 +1,11 @@ import { resolve } from "node:path"; import { PROJECT_COMPOSE_FILENAME } from "../constants.ts"; +import { + type AutostartGlobalView, + projectAutostartFromGlobal, + readAutostartServiceReady, + serializeAutostartGlobal, +} from "../lib/autostart-view.ts"; import { createOperationTimings } from "../lib/operation-timings.ts"; import { resolveProjectMeta } from "../lib/project-meta.ts"; import { @@ -7,7 +13,10 @@ import { serializeProjectSummary, serializeProjectView, } from "../lib/project-views.ts"; -import { readProjectsRegistry } from "../lib/projects-registry.ts"; +import { + getDefaultAutostartGlobal, + readProjectsRegistry, +} from "../lib/projects-registry.ts"; import type { RuntimeProject } from "../lib/runtime-projects.ts"; import { autoRegisterRuntimeHackProjects, @@ -49,6 +58,7 @@ export type RuntimeSnapshot = { }; export type ProjectsPayload = { + readonly autostart_global: AutostartGlobalView; readonly detail_level?: "summary"; readonly profiling?: Record; readonly generated_at: string; @@ -146,12 +156,15 @@ export function createRuntimeCache(opts: { readonly onRefresh?: (snapshot: RuntimeSnapshot) => void; readonly deps?: { readonly readProjectsRegistry?: typeof readProjectsRegistry; + readonly readAutostartServiceReady?: typeof readAutostartServiceReady; readonly buildProjectViews?: typeof buildProjectViews; readonly serializeProjectView?: typeof serializeProjectView; readonly resolveProjectMeta?: typeof resolveProjectMeta; }; }): RuntimeCache { const deps = { + readAutostartServiceReady: + opts.deps?.readAutostartServiceReady ?? readAutostartServiceReady, readProjectsRegistry: opts.deps?.readProjectsRegistry ?? readProjectsRegistry, buildProjectViews: opts.deps?.buildProjectViews ?? buildProjectViews, @@ -359,11 +372,25 @@ export function createRuntimeCache(opts: { ) ) : []; + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await deps.readAutostartServiceReady() + ); + const registeredById = new Map( + registry.projects.map((project) => [project.id, project]) + ); const projects = profiler.measureSync("projection_ms", () => views.map((view, i) => ({ ...(summary ? serializeProjectSummary(view) : deps.serializeProjectView(view)), + autostart: projectAutostartFromGlobal({ + project: + view.projectId === undefined + ? undefined + : registeredById.get(view.projectId), + global, + }), ...(includeMeta ? { meta: metas[i] ?? null } : {}), })) ); @@ -382,6 +409,7 @@ export function createRuntimeCache(opts: { } : {}), generated_at: new Date().toISOString(), + autostart_global: global, filter, include_global: includeGlobal, include_unregistered: includeUnregistered, diff --git a/src/lib/autostart-view.ts b/src/lib/autostart-view.ts new file mode 100644 index 000000000..c1bdf13c4 --- /dev/null +++ b/src/lib/autostart-view.ts @@ -0,0 +1,151 @@ +import { resolveLaunchdAccess } from "../daemon/launchd.ts"; +import { resolveDaemonPaths } from "../daemon/paths.ts"; +import { + resolveSystemdAccess, + resolveSystemdUnitPath, +} from "../daemon/systemd.ts"; +import { pathExists } from "./fs.ts"; +import type { + AutostartGlobal, + RegisteredProject, +} from "./projects-registry.ts"; + +export interface AutostartLoginRunView { + readonly in_progress: boolean; + readonly started_at: string | null; + /** Registry project IDs, including active starts until their outcome is recorded. */ + readonly pending: readonly string[]; + readonly started: readonly string[]; + readonly failed: readonly string[]; +} +export interface AutostartGlobalView { + readonly paused: boolean; + readonly login_run: AutostartLoginRunView; + /** An installed service file was observed; this does not qualify its manager or executable. */ + readonly service_ready: boolean; +} +export interface ProjectAutostartView { + readonly enabled: boolean; + readonly state: "off" | "on" | "paused" | "starting" | "needs_attention"; + readonly paused_reason: "manual_stop" | null; + readonly failures: { + readonly count: number; + readonly last_at: string | null; + readonly failed_services: readonly string[]; + readonly last_error_code: string | null; + }; + readonly max_failures: number; + readonly branch: string | null; + readonly paused: boolean; + readonly last_result: NonNullable< + RegisteredProject["autostart"] + >["lastResult"]; +} + +/** Unit 1 has no restart loop; failure-budget fields reserve the desktop contract. */ +export function serializeProjectAutostart( + marker: RegisteredProject["autostart"], + opts?: { + readonly starting?: boolean; + readonly globallyPaused?: boolean; + } +): ProjectAutostartView { + let state: ProjectAutostartView["state"] = "off"; + if (marker) { + state = "on"; + if (opts?.starting) { + state = "starting"; + } + if (marker.paused || opts?.globallyPaused) { + state = "paused"; + } + if (marker.invalid) { + state = "needs_attention"; + } + } + return { + enabled: marker !== undefined, + state, + paused_reason: marker?.paused && !marker.invalid ? "manual_stop" : null, + failures: { + count: 0, + last_at: null, + failed_services: [], + last_error_code: null, + }, + max_failures: 3, + branch: marker?.branch ?? null, + paused: marker?.paused ?? false, + last_result: marker?.lastResult + ? { + status: marker.lastResult.status, + reason: marker.lastResult.reason, + at: marker.lastResult.at, + } + : null, + }; +} + +/** File-only service observation. Access opt-outs are honored before reading any path. */ +export async function readAutostartServiceReady(opts?: { + readonly platform?: string; + readonly env?: Readonly>; + readonly exists?: (path: string) => Promise; + readonly servicePath?: string; +}): Promise { + const platform = opts?.platform ?? process.platform; + const env = opts?.env ?? process.env; + const exists = opts?.exists ?? pathExists; + if ( + (env.HACK_DAEMON_AUTOSTART_DISABLE ?? "").trim() === "1" || + (env.HACK_CANDIDATE_ENTRYPOINT ?? "").trim().length > 0 + ) { + return false; + } + let servicePath: string; + if (platform === "darwin") { + if (!resolveLaunchdAccess({ env }).allowed) { + return false; + } + servicePath = opts?.servicePath ?? resolveDaemonPaths({}).launchdPlistPath; + } else if (platform === "linux") { + if (!resolveSystemdAccess({ env }).allowed) { + return false; + } + servicePath = opts?.servicePath ?? resolveSystemdUnitPath({ env }); + } else { + return false; + } + return await exists(servicePath); +} + +export function projectAutostartFromGlobal(opts: { + readonly project: RegisteredProject | undefined; + readonly global: AutostartGlobalView; +}): ProjectAutostartView { + return serializeProjectAutostart(opts.project?.autostart, { + globallyPaused: opts.global.paused, + starting: + opts.global.login_run.in_progress && + opts.project !== undefined && + opts.global.login_run.pending.includes(opts.project.id), + }); +} + +/** Private run and boot identities never cross the desktop JSON boundary. */ +export function serializeAutostartGlobal( + state: AutostartGlobal, + serviceReady: boolean +): AutostartGlobalView { + return { + paused: state.paused, + login_run: { + in_progress: state.loginRun.inProgress, + started_at: state.loginRun.startedAt, + pending: [...state.loginRun.pending], + started: [...state.loginRun.started], + failed: [...state.loginRun.failed], + }, + service_ready: serviceReady, + }; +} diff --git a/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index e17e781d0..1abd234f8 100644 --- a/src/lib/native-authored-command.ts +++ b/src/lib/native-authored-command.ts @@ -441,7 +441,7 @@ async function showStatus(options: { ]), }); const policy = (await options.statusMetadata?.())?.autostart; - if (policy) { + if (policy?.enabled) { await display.kv({ entries: [ ["Autostart", policy.paused ? "paused" : "always-on"], diff --git a/src/lib/native-compose-command.ts b/src/lib/native-compose-command.ts index 954500cc7..1281c1663 100644 --- a/src/lib/native-compose-command.ts +++ b/src/lib/native-compose-command.ts @@ -4,6 +4,7 @@ import { NATIVE_RUN_EMPTY_SELECTION_REFUSAL } from "../backends/native-project-o import type { RuntimeBaseOptions } from "../backends/runtime-backend.ts"; import { CliUsageError } from "../cli/command.ts"; import { resolveRequestedEnvName } from "../cli/env-selection.ts"; +import type { AutostartGlobalView } from "./autostart-view.ts"; import { emitCliResult, errorResult, @@ -160,6 +161,7 @@ export type NativeComposeCommandOptions = { /** Add machine policy only for status callers; ordinary ps keeps its existing shape. */ readonly statusMetadata?: () => Promise<{ readonly autostart: ReturnType; + readonly autostart_global: AutostartGlobalView; }>; readonly cwd: string; readonly path?: string; diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index c824e405c..72947b2ea 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -9,6 +9,11 @@ import { } from "../constants.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { parseSessionBase } from "../mux/session-names.ts"; +import { serializeProjectAutostart } from "./autostart-view.ts"; + +// biome-ignore lint/performance/noBarrelFile: Preserve the existing project policy serializer import contract. +export { serializeProjectAutostart } from "./autostart-view.ts"; + import { pathExists, readTextFile } from "./fs.ts"; import { isRecord } from "./guards.ts"; import { @@ -397,31 +402,6 @@ function buildUnregisteredProjectView(opts: { }; } -/** Stable desktop-facing machine policy; per-boot admission bookkeeping remains internal. */ -export function serializeProjectAutostart( - marker: RegisteredProject["autostart"] -): { - readonly branch: string | null; - readonly paused: boolean; - readonly last_result: NonNullable< - RegisteredProject["autostart"] - >["lastResult"]; -} | null { - return marker - ? { - branch: marker.branch, - paused: marker.paused, - last_result: marker.lastResult - ? { - status: marker.lastResult.status, - reason: marker.lastResult.reason, - at: marker.lastResult.at, - } - : null, - } - : null; -} - /** Summary projection deliberately excludes container labels, mounts and command definitions. */ export function serializeProjectSummary( view: ProjectView diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 7166b54be..042628296 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { type FileHandle, open, realpath } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -42,6 +42,9 @@ const REGISTRY_VERSION = 1 as const; const REGISTRY_LOCK_FILENAME = `${GLOBAL_PROJECTS_REGISTRY_FILENAME}.lock`; const REGISTRY_TOUCH_INTERVAL_MS = 60_000; const AUTOSTART_REGISTRY_LIMIT_BYTES = 16 * 1024 * 1024; +/** Progress records are bounded independently of the overall registry byte cap. */ +export const AUTOSTART_LOGIN_PROJECT_LIMIT = 1000; +const AUTOSTART_PROJECT_ID_LIMIT = 128; export interface RegisteredProjectWorktree { readonly path: string; @@ -67,6 +70,24 @@ export interface ProjectAutostart { readonly invalid?: true; } +/** Login progress uses project IDs. Run identity is private admission bookkeeping. */ +export interface AutostartLoginRun { + readonly runId: string | null; + readonly bootId: string | null; + readonly inProgress: boolean; + readonly startedAt: string | null; + readonly pending: readonly string[]; + readonly started: readonly string[]; + readonly failed: readonly string[]; +} + +/** Global pause is independent of per-project manual-stop pauses. */ +export interface AutostartGlobal { + readonly paused: boolean; + readonly loginRun: AutostartLoginRun; + readonly invalid?: true; +} + // Invalid markers remain repairable and survive unrelated registry writes byte-for-JSON-value. // The weak key follows the marker through project spreads without exposing raw data in views. const invalidAutostartValues = new WeakMap(); @@ -88,6 +109,7 @@ export interface RegisteredProject { export interface ProjectsRegistry { readonly version: typeof REGISTRY_VERSION; readonly projects: readonly RegisteredProject[]; + readonly autostartGlobal?: AutostartGlobal; } export class AmbiguousProjectIdError extends Error { @@ -176,7 +198,7 @@ export class AutostartRegistryTrustError extends HackCliError { super({ code: "E_PERMISSION", message: - "Autostart cannot trust projects.json. Use a regular file owned by the current user with mode 0600, without a symbolic link; keep it below 16 MiB. Values omitted.", + "Autostart cannot trust projects.json. Preserve the old file outside the active registry, then rebuild a fresh current-user-owned regular registry and re-enable only reviewed projects. Do not repair writable files with in-place chmod: existing writer descriptors remain writable. Fresh files must have mode 0600, no symbolic link, and stay below 16 MiB. Values omitted.", }); this.name = "AutostartRegistryTrustError"; } @@ -374,7 +396,8 @@ export async function readProjectsRegistryStrict( */ async function readProjectsRegistryForMutation(): Promise { const observed = await readProjectsRegistry(); - return observed.projects.some((project) => project.autostart) + return observed.autostartGlobal || + observed.projects.some((project) => project.autostart) ? await readProjectsRegistry({ requireAutostartTrust: true }) : observed; } @@ -462,6 +485,7 @@ async function upsertObservedRegistration(input: { }); return await commitRegistrationUpdate({ + registry: current, update, registryPath, signal: opts.signal, @@ -473,6 +497,7 @@ async function upsertObservedRegistration(input: { } async function commitRegistrationUpdate(opts: { + readonly registry: ProjectsRegistry; readonly update: Awaited>; readonly registryPath: string; readonly signal?: AbortSignal; @@ -486,7 +511,7 @@ async function commitRegistrationUpdate(opts: { await assertLegacyProjectDirectory({ projectDir: opts.projectDir }); await writeRegistryAtomic( opts.registryPath, - { version: REGISTRY_VERSION, projects: status.projects }, + { ...opts.registry, projects: status.projects }, opts.signal ); } @@ -553,6 +578,7 @@ async function refreshUnchangedPrimary(opts: { return { status: "noop", project: primary }; } return await commitRegistrationUpdate({ + registry: current, registryPath, projectDir: opts.observation.projectDir, update: await updatePrimaryRegistration({ @@ -759,6 +785,203 @@ type AutostartRegistryOptions = { readonly signal?: AbortSignal; }; +/** Legacy registries default to unpaused with no observed login run. */ +export function getDefaultAutostartGlobal(): AutostartGlobal { + return { + paused: false, + loginRun: { + runId: null, + bootId: null, + inProgress: false, + startedAt: null, + pending: [], + started: [], + failed: [], + }, + }; +} + +/** Automatic-start policy must be read from a trusted machine registry. */ +export async function readAutostartGlobal( + opts: AutostartRegistryOptions = {} +): Promise { + const registry = await readTrustedAutostartRegistry(opts); + return registry.autostartGlobal ?? getDefaultAutostartGlobal(); +} + +/** Global pause masks every opt-in, including future markers; manual-stop intent is retained. */ +export async function setAutostartGlobalPaused( + opts: AutostartRegistryOptions & { readonly paused: boolean } +): Promise { + return await mutateAutostartRegistry({ + ...opts, + update: (current) => { + const old = current.autostartGlobal; + const global: AutostartGlobal = { + ...(old?.invalid + ? getDefaultAutostartGlobal() + : (old ?? getDefaultAutostartGlobal())), + paused: opts.paused, + }; + return { next: { ...current, autostartGlobal: global }, value: global }; + }, + }); +} + +/** Begin a new owned progress record. A globally paused machine starts no run. */ +export async function beginAutostartLoginRun( + opts: AutostartRegistryOptions & { + readonly bootId: string; + readonly startedAt: string; + readonly pendingIds: readonly string[]; + } +): Promise { + if (!(opts.bootId.trim() && Number.isFinite(Date.parse(opts.startedAt)))) { + throw new Error( + "Autostart login requires a boot identity and valid start time." + ); + } + const pending = [...new Set(opts.pendingIds)]; + if (!isAutostartLoginProjectIds(pending)) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Autostart login progress supports at most 1000 project IDs, each 1 to 128 characters. No projects were started.", + }); + } + return await mutateAutostartRegistry({ + ...opts, + update: (current) => { + const global = current.autostartGlobal ?? getDefaultAutostartGlobal(); + if (global.paused) { + return { next: current, value: null }; + } + const run: AutostartLoginRun = { + runId: randomUUID(), + bootId: opts.bootId, + inProgress: true, + startedAt: opts.startedAt, + pending, + started: [], + failed: [], + }; + return { + next: { ...current, autostartGlobal: { ...global, loginRun: run } }, + value: run, + }; + }, + }); +} + +/** Complete one pending project; duplicate and superseded run receipts do nothing. */ +export async function recordAutostartLoginOutcome( + opts: AutostartRegistryOptions & { + readonly runId: string; + readonly id: string; + readonly succeeded: boolean; + /** User intent/cancellation is neither success nor a startup failure. */ + readonly cancelled?: boolean; + } +): Promise { + return await mutateAutostartRegistry({ + ...opts, + update: (current) => { + const global = current.autostartGlobal ?? getDefaultAutostartGlobal(); + const run = global.loginRun; + if ( + !run.inProgress || + run.runId !== opts.runId || + !run.pending.includes(opts.id) + ) { + return { next: current, value: false }; + } + const loginRun: AutostartLoginRun = { + ...run, + pending: run.pending.filter((id) => id !== opts.id), + started: opts.succeeded ? [...run.started, opts.id] : run.started, + failed: + opts.succeeded || opts.cancelled + ? run.failed + : [...run.failed, opts.id], + }; + return { + next: { ...current, autostartGlobal: { ...global, loginRun } }, + value: true, + }; + }, + }); +} + +/** Close a run; unfinished or cancelled work has no observed failure outcome. */ +export async function finishAutostartLoginRun( + opts: AutostartRegistryOptions & { readonly runId: string } +): Promise { + return await mutateAutostartRegistry({ + ...opts, + update: (current) => { + const global = current.autostartGlobal ?? getDefaultAutostartGlobal(); + const run = global.loginRun; + if (!run.inProgress || run.runId !== opts.runId) { + return { next: current, value: false }; + } + const loginRun: AutostartLoginRun = { + ...run, + inProgress: false, + pending: [], + failed: run.failed, + }; + return { + next: { ...current, autostartGlobal: { ...global, loginRun } }, + value: true, + }; + }, + }); +} + +async function readTrustedAutostartRegistry( + opts: AutostartRegistryOptions +): Promise { + const read = await readProjectsRegistryStrict({ + registryPath: opts.registryPath, + requireAutostartTrust: true, + }); + if (read.kind === "invalid" || (read.kind === "valid" && read.rejected > 0)) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Projects registry is invalid; autostart intent was not changed.", + }); + } + return read.kind === "valid" + ? read.registry + : { version: REGISTRY_VERSION, projects: [] }; +} + +/** Serialize global policy, progress and project intent with all existing registry writers. */ +async function mutateAutostartRegistry( + opts: AutostartRegistryOptions & { + readonly update: (current: ProjectsRegistry) => { + readonly next: ProjectsRegistry; + readonly value: T; + }; + } +): Promise { + const registryPath = opts.registryPath ?? getRegistryPath(); + await ensureDir(dirname(registryPath)); + return await withProjectsRegistryLock({ + lockPath: `${registryPath}.lock`, + signal: opts.signal, + run: async () => { + const current = await readTrustedAutostartRegistry({ registryPath }); + const { next, value } = opts.update(current); + if (next !== current) { + await writeRegistryAtomic(registryPath, next, opts.signal); + } + return value; + }, + }); +} + /** Explicit machine opt-in. Re-adding an unchanged target preserves its boot claim. */ export async function setProjectAutostart( opts: AutostartRegistryOptions & { @@ -850,6 +1073,7 @@ export async function claimProjectAutostart( let claimed: RegisteredProject | null = null; await mutateProjectAutostart({ ...opts, + respectGlobalPause: true, update: (project) => { const marker = project.autostart; if ( @@ -902,6 +1126,7 @@ export async function recordProjectAutostartResult( async function mutateProjectAutostart( opts: AutostartRegistryOptions & { readonly id?: string; + readonly respectGlobalPause?: boolean; readonly update: (project: RegisteredProject) => RegisteredProject; } ): Promise { @@ -911,24 +1136,10 @@ async function mutateProjectAutostart( lockPath: `${registryPath}.lock`, signal: opts.signal, run: async () => { - const read = await readProjectsRegistryStrict({ - registryPath, - requireAutostartTrust: true, - }); - if ( - read.kind === "invalid" || - (read.kind === "valid" && read.rejected > 0) - ) { - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: - "Projects registry is invalid; autostart intent was not changed.", - }); + const current = await readTrustedAutostartRegistry({ registryPath }); + if (opts.respectGlobalPause && current.autostartGlobal?.paused) { + return []; } - const current = - read.kind === "valid" - ? read.registry - : { version: REGISTRY_VERSION, projects: [] }; if (opts.id !== undefined) { selectRegisteredProjectById({ projects: current.projects, @@ -991,7 +1202,7 @@ export async function removeProjectsById(opts: { await writeRegistryAtomic( getRegistryPath(), { - version: REGISTRY_VERSION, + ...current, projects: next, }, opts.signal @@ -1048,7 +1259,77 @@ function parseRegistry(value: unknown): ProjectsRegistry | null { } } - return { version: REGISTRY_VERSION, projects }; + const autostartGlobal = parseAutostartGlobal(value.autostartGlobal); + return { + version: REGISTRY_VERSION, + projects, + ...(autostartGlobal ? { autostartGlobal } : {}), + }; +} + +/** Invalid/future global policy remains visible, preserved and incapable of automatic starts. */ +function parseAutostartGlobal(value: unknown): AutostartGlobal | undefined { + if (value === undefined) { + return undefined; + } + if ( + isRecord(value) && + typeof value.paused === "boolean" && + isRecord(value.loginRun) + ) { + const run = value.loginRun; + if ( + (run.runId === null || typeof run.runId === "string") && + (run.bootId === null || typeof run.bootId === "string") && + typeof run.inProgress === "boolean" && + (run.startedAt === null || + (typeof run.startedAt === "string" && + Number.isFinite(Date.parse(run.startedAt)))) && + isAutostartLoginProjectIds(run.pending) && + isAutostartLoginProjectIds(run.started) && + isAutostartLoginProjectIds(run.failed) && + run.pending.length + run.started.length + run.failed.length <= + AUTOSTART_LOGIN_PROJECT_LIMIT && + (!run.inProgress || Boolean(run.runId && run.bootId && run.startedAt)) + ) { + return { + ...value, + paused: value.paused, + loginRun: { + ...run, + runId: run.runId, + bootId: run.bootId, + inProgress: run.inProgress, + startedAt: run.startedAt, + pending: run.pending, + started: run.started, + failed: run.failed, + }, + }; + } + } + const global: AutostartGlobal = { + ...getDefaultAutostartGlobal(), + paused: true, + invalid: true, + }; + invalidAutostartValues.set(global, value); + return global; +} + +function isAutostartLoginProjectIds( + value: unknown +): value is readonly string[] { + return ( + Array.isArray(value) && + value.length <= AUTOSTART_LOGIN_PROJECT_LIMIT && + value.every( + (id: unknown) => + typeof id === "string" && + id.length > 0 && + id.length <= AUTOSTART_PROJECT_ID_LIMIT + ) + ); } function parseProject(value: unknown): RegisteredProject | null { @@ -1200,22 +1481,30 @@ async function writeRegistryAtomic( registry: ProjectsRegistry, signal?: AbortSignal ): Promise { + const text = `${JSON.stringify( + registry, + (_key, value: unknown) => { + if ( + typeof value === "object" && + value !== null && + invalidAutostartValues.has(value) + ) { + return invalidAutostartValues.get(value); + } + return value; + }, + 2 + )}\n`; + if ( + (registry.autostartGlobal || + registry.projects.some((project) => project.autostart)) && + Buffer.byteLength(text) > AUTOSTART_REGISTRY_LIMIT_BYTES + ) { + throw new AutostartRegistryTrustError(); + } await writeProjectsRegistryAtomic({ path, - text: `${JSON.stringify( - registry, - (_key, value: unknown) => { - if ( - typeof value === "object" && - value !== null && - invalidAutostartValues.has(value) - ) { - return invalidAutostartValues.get(value); - } - return value; - }, - 2 - )}\n`, + text, signal, }); } diff --git a/tests/autostart-cli.test.ts b/tests/autostart-cli.test.ts index a6b6b0252..cdd976f2d 100644 --- a/tests/autostart-cli.test.ts +++ b/tests/autostart-cli.test.ts @@ -1,10 +1,14 @@ import { afterAll, beforeAll, beforeEach, expect, spyOn, test } from "bun:test"; import { parsePositionalsForCommand } from "../src/cli/command.ts"; +import { runCli } from "../src/cli/run.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; import { autostartCommand } from "../src/commands/autostart.ts"; import { __testOnlyProjectsCommand } from "../src/commands/projects.ts"; +import { serializeAutostartGlobal } from "../src/lib/autostart-view.ts"; +import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; import { serializeProjectAutostart } from "../src/lib/project-views.ts"; import type { RegisteredProject } from "../src/lib/projects-registry.ts"; +import { getDefaultAutostartGlobal } from "../src/lib/projects-registry.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const marker = { @@ -24,6 +28,12 @@ const project: RegisteredProject = { }; const mutations: unknown[] = []; let reads = 0; +let globalState = getDefaultAutostartGlobal(); +const viewMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/autostart-view.ts", + overrides: { readAutostartServiceReady: async () => false }, +}); const linkedRoots = new Set(); const gitMock = await registerScopedModuleMock({ importerPath: import.meta.path, @@ -39,7 +49,11 @@ const registryMock = await registerScopedModuleMock({ overrides: { readProjectsRegistry: () => { reads += 1; - return Promise.resolve({ version: 1, projects: [project] }); + return Promise.resolve({ + version: 1, + projects: [project], + autostartGlobal: globalState, + }); }, setProjectAutostart: (options: unknown) => { mutations.push(options); @@ -47,7 +61,13 @@ const registryMock = await registerScopedModuleMock({ }, removeProjectAutostart: (options: unknown) => { mutations.push(options); - return Promise.resolve(project); + const { autostart: _marker, ...withoutMarker } = project; + return Promise.resolve(withoutMarker); + }, + setAutostartGlobalPaused: ({ paused }: { readonly paused: boolean }) => { + globalState = { ...globalState, paused }; + mutations.push({ global: true, paused }); + return Promise.resolve(globalState); }, setProjectAutostartPaused: (options: unknown) => { mutations.push(options); @@ -56,17 +76,20 @@ const registryMock = await registerScopedModuleMock({ }, }); beforeAll(() => { + viewMock.activate(); registryMock.activate(); gitMock.activate(); }); afterAll(() => { gitMock.deactivate(); registryMock.deactivate(); + viewMock.deactivate(); }); beforeEach(() => { mutations.length = 0; reads = 0; linkedRoots.clear(); + globalState = getDefaultAutostartGlobal(); }); const ctx = { cwd: "/example/project/subdirectory", cli: CLI_SPEC }; const raw = { argv: [], positionals: [] }; @@ -89,7 +112,7 @@ test("autostart add selects current registered project and passes branch without autostartCommand.subcommands[0].handler({ ctx, args: { - options: { branch: "preview" }, + options: { branch: "preview", project: undefined, json: false }, positionals: parsePositionalsForCommand( autostartCommand.subcommands[0].positionals, [] @@ -103,12 +126,12 @@ test("autostart add selects current registered project and passes branch without expect(result.output).toContain("hack daemon install"); }); -test("pause and resume without a project select every marker", async () => { +test("pause and resume without a project change global intent independently of project markers", async () => { await capture(() => autostartCommand.subcommands[3].handler({ ctx, args: { - options: {}, + options: { project: undefined, json: false }, positionals: parsePositionalsForCommand( autostartCommand.subcommands[0].positionals, [] @@ -121,7 +144,7 @@ test("pause and resume without a project select every marker", async () => { autostartCommand.subcommands[4].handler({ ctx, args: { - options: {}, + options: { project: undefined, json: false }, positionals: parsePositionalsForCommand( autostartCommand.subcommands[0].positionals, [] @@ -131,10 +154,10 @@ test("pause and resume without a project select every marker", async () => { }) ); expect(mutations).toEqual([ - { id: undefined, paused: true }, - { id: undefined, paused: false }, + { global: true, paused: true }, + { global: true, paused: false }, ]); - expect(reads).toBe(0); + expect(reads).toBe(2); }); test("malformed project selector refuses before registry access or mutation", async () => { @@ -142,7 +165,7 @@ test("malformed project selector refuses before registry access or mutation", as autostartCommand.subcommands[1].handler({ ctx, args: { - options: {}, + options: { project: undefined, json: false }, positionals: parsePositionalsForCommand( autostartCommand.subcommands[1].positionals, ["KEY=value"] @@ -163,16 +186,48 @@ test("autostart list JSON exposes stable policy and result fields without admiss }) ); expect(JSON.parse(result.output)).toEqual({ + autostart_global: serializeAutostartGlobal( + getDefaultAutostartGlobal(), + false + ), projects: [ { project_id: "example-id", name: "example", - autostart: { branch: null, paused: false, last_result: null }, + autostart: { + enabled: true, + state: "on", + paused_reason: null, + failures: { + count: 0, + last_at: null, + failed_services: [], + last_error_code: null, + }, + max_failures: 3, + branch: null, + paused: false, + last_result: null, + }, }, ], }); expect(result.output).not.toContain("private-admission-id"); - expect(serializeProjectAutostart(undefined)).toBeNull(); + expect(serializeProjectAutostart(undefined)).toEqual({ + enabled: false, + state: "off", + paused_reason: null, + failures: { + count: 0, + last_at: null, + failed_services: [], + last_error_code: null, + }, + max_failures: 3, + branch: null, + paused: false, + last_result: null, + }); expect( serializeProjectAutostart({ ...marker, @@ -184,6 +239,16 @@ test("autostart list JSON exposes stable policy and result fields without admiss }, }) ).toEqual({ + enabled: true, + state: "paused", + paused_reason: "manual_stop", + failures: { + count: 0, + last_at: null, + failed_services: [], + last_error_code: null, + }, + max_failures: 3, branch: null, paused: true, last_result: { @@ -212,13 +277,20 @@ test("fresh registry policy overrides cached daemon results and removed markers" projects: [{ ...project, autostart: { ...marker, paused: true } }], }) ).toEqual({ + autostart_global: serializeAutostartGlobal( + getDefaultAutostartGlobal(), + false + ), projects: [ { project_id: "example-id", name: "example", - autostart: { branch: null, paused: true, last_result: null }, + autostart: serializeProjectAutostart({ ...marker, paused: true }), + }, + { + project_id: "removed-id", + autostart: serializeProjectAutostart(undefined), }, - { project_id: "removed-id", autostart: null }, ], runtime_ok: true, }); @@ -232,7 +304,7 @@ for (const linkedRoot of [ctx.cwd, project.repoRoot]) { autostartCommand.subcommands[0].handler({ ctx, args: { - options: { branch: "preview" }, + options: { branch: "preview", project: undefined, json: false }, positionals: parsePositionalsForCommand( autostartCommand.subcommands[0].positionals, ["example"] @@ -244,3 +316,56 @@ for (const linkedRoot of [ctx.cwd, project.repoRoot]) { expect(mutations).toEqual([]); }); } + +for (const action of ["add", "remove", "pause", "resume"]) { + test(`autostart ${action} accepts --project with a single JSON success envelope`, async () => { + try { + const result = await capture(() => + runCli([ + "autostart", + action, + "--project", + "example", + "--json", + "--no-interactive", + ]) + ); + expect(result.code).toBe(0); + expect(result.output.trim().startsWith("{")).toBe(true); + expect(result.output.trim().endsWith("}")).toBe(true); + expect(JSON.parse(result.output)).toMatchObject({ + ok: true, + data: { + action, + projects: [ + { + project_id: "example-id", + autostart: { enabled: action !== "remove" }, + }, + ], + autostart_global: { paused: false, service_ready: false }, + }, + }); + } finally { + resetNoInteractiveFlagForTests(); + } + }); +} + +test("explicit --project and positional name refuse before any registry read", async () => { + const result = await capture(() => + runCli([ + "autostart", + "add", + "example", + "--project", + "example", + "--json", + "--no-interactive", + ]) + ); + resetNoInteractiveFlagForTests(); + expect(result.code).toBe(1); + expect(reads).toBe(0); + expect(mutations).toEqual([]); +}); diff --git a/tests/autostart-view.test.ts b/tests/autostart-view.test.ts new file mode 100644 index 000000000..6a8535dba --- /dev/null +++ b/tests/autostart-view.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { + projectAutostartFromGlobal, + readAutostartServiceReady, + serializeAutostartGlobal, + serializeProjectAutostart, +} from "../src/lib/autostart-view.ts"; +import { + getDefaultAutostartGlobal, + type RegisteredProject, +} from "../src/lib/projects-registry.ts"; + +const project: RegisteredProject = { + id: "example-id", + name: "example", + repoRoot: "/example/project", + projectDir: "/example/project/.hack", + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + autostart: { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: "private-boot-id", + }, +}; + +test("desktop policy states distinguish off, starting, global pause and manual stop", () => { + const global = serializeAutostartGlobal( + { + ...getDefaultAutostartGlobal(), + loginRun: { + runId: "private-run-id", + bootId: "private-boot-id", + inProgress: true, + startedAt: "2026-01-01T00:00:00Z", + pending: [project.id], + started: ["ready-id"], + failed: ["failed-id"], + }, + }, + true + ); + expect(projectAutostartFromGlobal({ project, global })).toMatchObject({ + enabled: true, + state: "starting", + paused_reason: null, + failures: { + count: 0, + last_at: null, + failed_services: [], + last_error_code: null, + }, + max_failures: 3, + }); + expect( + projectAutostartFromGlobal({ project, global: { ...global, paused: true } }) + ).toMatchObject({ state: "paused", paused_reason: null }); + expect( + serializeProjectAutostart({ ...project.autostart!, paused: true }) + ).toMatchObject({ state: "paused", paused_reason: "manual_stop" }); + expect( + serializeProjectAutostart({ + ...project.autostart!, + paused: true, + invalid: true, + }) + ).toMatchObject({ state: "needs_attention", paused_reason: null }); + expect(serializeProjectAutostart(undefined)).toMatchObject({ + enabled: false, + state: "off", + paused_reason: null, + max_failures: 3, + }); + expect(global).toEqual({ + paused: false, + service_ready: true, + login_run: { + in_progress: true, + started_at: "2026-01-01T00:00:00Z", + pending: ["example-id"], + started: ["ready-id"], + failed: ["failed-id"], + }, + }); + expect(JSON.stringify(global)).not.toContain("private-"); +}); + +for (const platform of ["linux", "darwin"]) { + test(`${platform} service readiness uses only the injected installed-file observation`, async () => { + const paths: string[] = []; + expect( + await readAutostartServiceReady({ + platform, + env: {}, + servicePath: "/example/service", + exists: (path) => { + paths.push(path); + return Promise.resolve(true); + }, + }) + ).toBe(true); + expect(paths).toEqual(["/example/service"]); + expect( + await readAutostartServiceReady({ + platform, + env: {}, + servicePath: "/example/service", + exists: async () => false, + }) + ).toBe(false); + }); + test(`${platform} service readiness honors disabled and isolated launchers without filesystem reads`, async () => { + let reads = 0; + for (const env of [ + { HACK_DAEMON_AUTOSTART_DISABLE: "1" }, + { HACK_HOME: "/example/isolated", HOME: "/example/home" }, + { HACK_CANDIDATE_ENTRYPOINT: "hack-next" }, + ]) { + expect( + await readAutostartServiceReady({ + platform, + env, + servicePath: "/example/service", + exists: () => { + reads += 1; + return Promise.resolve(true); + }, + }) + ).toBe(false); + } + expect(reads).toBe(0); + }); +} diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index 26026f0bd..18f450016 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type AutostartDependencies, + autostartComposeModel, autostartRuntimeRefusal, classifyAutostartContainers, classifyAutostartExit, @@ -12,7 +13,13 @@ import { runAutostartSubprocess, } from "../src/daemon/autostart.ts"; import { + beginAutostartLoginRun, + claimProjectAutostart, + readAutostartGlobal, readProjectsRegistry, + removeProjectAutostart, + setAutostartGlobalPaused, + setProjectAutostart, setProjectAutostartPaused, } from "../src/lib/projects-registry.ts"; @@ -172,6 +179,9 @@ test("pause while inspecting prevents dispatch", async () => { }; await run(); expect(starts).toEqual([]); + expect((await readAutostartGlobal({ registryPath })).loginRun.failed).toEqual( + [] + ); }); test("engine wait has a finite bound and records a closed skip reason", async () => { @@ -273,6 +283,9 @@ test("shutdown cancellation prevents any further launch", async () => { }; await run(abort.signal); expect(starts.map((start) => start.id)).toEqual(["alpha"]); + expect((await readAutostartGlobal({ registryPath })).loginRun.failed).toEqual( + [] + ); }); test("only classified safe reasons are persisted; missing keys and prompts are skipped", () => { @@ -490,3 +503,234 @@ test("keychain preflight and subprocess termination have distinct closed reasons ) ).toEqual({ exitCode: 124, stdout: "", reason: "daemon_stopped" }); }); + +test("independent global pause blocks existing and newly marked projects", async () => { + await seed(["alpha", "bravo"]); + await removeProjectAutostart({ id: "bravo", registryPath }); + await setAutostartGlobalPaused({ paused: true, registryPath }); + await setProjectAutostart({ id: "bravo", registryPath }); + await run(); + expect(events).toEqual([]); + expect(starts).toEqual([]); + expect((await readAutostartGlobal({ registryPath })).paused).toBe(true); + expect( + (await readProjectsRegistry({ registryPath })).projects.every( + (project) => project.autostart?.paused === false + ) + ).toBe(true); +}); + +test("global pause during inspection blocks dispatch and preserves manual project intent", async () => { + await seed(["alpha", "bravo"]); + await setProjectAutostartPaused({ id: "bravo", paused: true, registryPath }); + deps = { + ...deps, + inspect: async () => { + await setAutostartGlobalPaused({ paused: true, registryPath }); + return "stopped"; + }, + }; + await run(); + expect(starts).toEqual([]); + expect( + (await results()).find((project) => project.id === "alpha")?.result?.reason + ).toBe("global_paused"); + await setAutostartGlobalPaused({ paused: false, registryPath }); + expect( + (await readProjectsRegistry({ registryPath })).projects.find( + (project) => project.id === "bravo" + )?.autostart?.paused + ).toBe(true); + const progress = (await readAutostartGlobal({ registryPath })).loginRun; + expect(progress).toMatchObject({ + inProgress: false, + pending: [], + started: [], + failed: [], + }); +}); + +test("global pause during engine or routing discovery prevents routing and project starts", async () => { + for (const during of ["engine", "routing"]) { + await seed(["alpha"]); + deps = { + ...deps, + engineReady: async () => { + if (during === "engine") { + await setAutostartGlobalPaused({ paused: true, registryPath }); + } + return true; + }, + routingInstalled: async () => { + if (during === "routing") { + await setAutostartGlobalPaused({ paused: true, registryPath }); + } + return true; + }, + }; + await run(); + expect(events).toEqual([]); + expect(starts).toEqual([]); + expect( + (await readAutostartGlobal({ registryPath })).loginRun + ).toMatchObject({ inProgress: false, pending: [], failed: [] }); + } +}); + +test("login progress is persisted before engine probing and separates successful and failed outcomes", async () => { + await seed(["bravo", "alpha"]); + deps = { + ...deps, + engineReady: async () => { + expect( + (await readAutostartGlobal({ registryPath })).loginRun + ).toMatchObject({ + inProgress: true, + startedAt: "1970-01-01T00:00:00.000Z", + pending: ["alpha", "bravo"], + started: [], + failed: [], + }); + return true; + }, + start: async (project) => { + const progress = (await readAutostartGlobal({ registryPath })).loginRun; + expect(progress.inProgress).toBe(true); + expect(progress.pending).toContain(project.id); + return project.id === "alpha" + ? { status: "started", reason: null } + : { status: "failed", reason: "start_failed" }; + }, + }; + await run(); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + pending: [], + started: ["alpha"], + failed: ["bravo"], + }); +}); + +test("cancelled and interrupted login runs close their pending progress without replaying claims", async () => { + await seed(["alpha", "bravo"]); + const abort = new AbortController(); + deps = { + ...deps, + engineReady: async () => { + abort.abort(); + return true; + }, + }; + await run(abort.signal); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + pending: [], + started: [], + failed: [], + }); + const previous = await beginAutostartLoginRun({ + bootId: "boot-1", + startedAt: "1970-01-01T00:00:00.000Z", + pendingIds: ["alpha", "bravo"], + registryPath, + }); + expect(previous?.inProgress).toBe(true); + await claimProjectAutostart({ id: "alpha", bootId: "boot-1", registryPath }); + await claimProjectAutostart({ id: "bravo", bootId: "boot-1", registryPath }); + await run(); + expect(starts).toEqual([]); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + runId: previous?.runId, + inProgress: false, + pending: [], + failed: [], + }); +}); + +test("a thrown project start records failure and finishes global progress", async () => { + await seed(["alpha"]); + deps = { + ...deps, + start: async () => { + throw new Error("example sensitive details"); + }, + }; + await run(); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + pending: [], + failed: ["alpha"], + }); + expect( + JSON.stringify(await readAutostartGlobal({ registryPath })) + ).not.toContain("example sensitive details"); +}); + +test("adoption requires every default service rather than a running subset", () => { + const model = autostartComposeModel({ + services: { web: {}, db: {}, debug: { profiles: ["debug"] } }, + }); + expect(model).not.toBeNull(); + expect([...(model?.requiredServices ?? [])]).toEqual(["web", "db"]); + expect( + classifyAutostartContainers( + "db|running|0", + model?.completedServices, + model?.requiredServices + ) + ).toBe("stopped"); + expect( + classifyAutostartContainers( + "web|running|0\ndb|running|0", + model?.completedServices, + model?.requiredServices + ) + ).toBe("running"); +}); + +test("required dependency targets include completed initializers but optional disabled profiles stay excluded", () => { + const model = autostartComposeModel({ + services: { + web: { + depends_on: { + init: { condition: "service_completed_successfully" }, + metrics: { required: false }, + }, + }, + init: { profiles: ["setup"] }, + metrics: { profiles: ["monitoring"] }, + }, + }); + expect([...(model?.requiredServices ?? [])]).toEqual(["web", "init"]); + expect( + classifyAutostartContainers( + "web|running|0", + model?.completedServices, + model?.requiredServices + ) + ).toBe("stopped"); + expect( + classifyAutostartContainers( + "web|running|0\ninit|exited|0", + model?.completedServices, + model?.requiredServices + ) + ).toBe("running"); +}); + +test("ambiguous Compose models cannot prove adoption", () => { + expect( + autostartComposeModel({ include: ["other.yml"], services: { web: {} } }) + ).toBeNull(); + expect( + autostartComposeModel({ + services: { web: { extends: { service: "base", file: "other.yml" } } }, + }) + ).toBeNull(); + expect( + autostartComposeModel({ services: { web: { depends_on: ["missing"] } } }) + ).toBeNull(); + expect( + autostartComposeModel({ services: { web: { profiles: "debug" } } }) + ).toBeNull(); +}); diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index ace8bf612..16ffc5773 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, + open, readFile, rename, rm, @@ -14,13 +15,20 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { + AUTOSTART_LOGIN_PROJECT_LIMIT, + beginAutostartLoginRun, claimProjectAutostart, + finishAutostartLoginRun, + getDefaultAutostartGlobal, isTrustedAutostartRegistryFile, + readAutostartGlobal, readProjectsRegistry, readProjectsRegistryStrict, + recordAutostartLoginOutcome, recordProjectAutostartResult, removeProjectAutostart, removeProjectsById, + setAutostartGlobalPaused, setProjectAutostart, setProjectAutostartPaused, touchProjectRegistration, @@ -415,7 +423,7 @@ test("autostart rejects group and other writable registries while ordinary reads ).rejects.toMatchObject({ code: "E_PERMISSION" }); await expect( readProjectsRegistryStrict({ registryPath, requireAutostartTrust: true }) - ).rejects.toThrow("regular file owned by the current user with mode 0600"); + ).rejects.toThrow("Do not repair writable files with in-place chmod"); } }); @@ -513,6 +521,34 @@ test("trusted mutations publish owner-private registry files", async () => { expect((await stat(registryPath)).mode & 0o777).toBe(0o600); }); +test("fresh registry replacement isolates a writer retained on the preserved unsafe inode", async () => { + await chmod(registryPath, 0o660); + const writer = await open(registryPath, "r+"); + try { + await rename(registryPath, join(root, "preserved-unsafe-registry.json")); + await writeFile( + registryPath, + JSON.stringify({ version: 1, projects: [entry("alpha")] }), + { mode: 0o600, flag: "wx" } + ); + await setProjectAutostart({ id: "alpha", registryPath }); + await writer.truncate(0); + await writer.write( + JSON.stringify({ version: 1, projects: [entry("beta")] }), + 0, + "utf8" + ); + const trusted = await readProjectsRegistry({ + registryPath, + requireAutostartTrust: true, + }); + expect(trusted.projects.map((project) => project.id)).toEqual(["alpha"]); + expect(trusted.projects[0]?.autostart?.paused).toBe(false); + } finally { + await writer.close(); + } +}); + async function createTrustProject() { const projectDir = join(root, "alpha", ".hack"); await mkdir(projectDir, { recursive: true }); @@ -561,3 +597,288 @@ test("ordinary writes retain trusted intent and can upgrade unmarked legacy perm expect((await read())?.autostart?.branch).toBe("preview"); expect((await stat(registryPath)).mode & 0o777).toBe(0o600); }); +test("legacy registries default to unpaused idle global state without a migration write", async () => { + const before = await readFile(registryPath, "utf8"); + expect(await readAutostartGlobal({ registryPath })).toEqual( + getDefaultAutostartGlobal() + ); + expect(await readFile(registryPath, "utf8")).toBe(before); + expect( + (await readProjectsRegistry({ registryPath })).autostartGlobal + ).toBeUndefined(); +}); + +test("global pause covers future markers and global resume preserves a manual stop", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); + await setAutostartGlobalPaused({ paused: true, registryPath }); + await setProjectAutostart({ id: "beta", registryPath }); + expect((await read("beta"))?.autostart?.paused).toBe(false); + expect( + await claimProjectAutostart({ id: "beta", bootId: "boot-a", registryPath }) + ).toBeNull(); + expect( + await beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + pendingIds: ["beta"], + registryPath, + }) + ).toBeNull(); + expect((await readAutostartGlobal({ registryPath })).paused).toBe(true); + await setAutostartGlobalPaused({ paused: false, registryPath }); + expect((await read())?.autostart?.paused).toBe(true); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); + expect( + await claimProjectAutostart({ id: "beta", bootId: "boot-a", registryPath }) + ).not.toBeNull(); +}); + +test("global policy survives ordinary upsert, touch, prune and marker writes", async () => { + const project = await createTrustProject(); + await setAutostartGlobalPaused({ paused: true, registryPath }); + await upsertProjectRegistration({ project }); + await touchProjectRegistration({ + project, + nowIso: "2027-01-01T00:00:00.000Z", + }); + await removeProjectsById({ ids: ["beta"] }); + await setProjectAutostart({ id: "alpha", registryPath }); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + await removeProjectAutostart({ id: "alpha", registryPath }); + expect(await readAutostartGlobal({ registryPath })).toEqual({ + ...getDefaultAutostartGlobal(), + paused: true, + }); +}); + +test("login progress persists outcomes and rejects stale run updates in the same boot", async () => { + const run = await beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + pendingIds: ["alpha", "beta", "alpha"], + registryPath, + }); + if (!run?.runId) { + throw new Error("Expected a login run"); + } + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: true, + pending: ["alpha", "beta"], + started: [], + failed: [], + }); + expect( + await recordAutostartLoginOutcome({ + runId: run.runId, + id: "beta", + succeeded: true, + registryPath, + }) + ).toBe(true); + expect( + await recordAutostartLoginOutcome({ + runId: run.runId, + id: "beta", + succeeded: false, + registryPath, + }) + ).toBe(false); + expect( + await recordAutostartLoginOutcome({ + runId: run.runId, + id: "alpha", + succeeded: false, + registryPath, + }) + ).toBe(true); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + pending: [], + started: ["beta"], + failed: ["alpha"], + }); + const next = await beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:01.000Z", + pendingIds: ["alpha"], + registryPath, + }); + if (!next) { + throw new Error("Expected a replacement login run"); + } + expect(next?.runId).not.toBe(run.runId); + expect( + await recordAutostartLoginOutcome({ + runId: run.runId, + id: "alpha", + succeeded: true, + registryPath, + }) + ).toBe(false); + expect( + await finishAutostartLoginRun({ runId: run.runId, registryPath }) + ).toBe(false); + expect((await readAutostartGlobal({ registryPath })).loginRun).toEqual(next); +}); + +test("closing interrupted login progress clears pending without inventing failures or claims", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + const run = await beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + pendingIds: ["alpha", "beta"], + registryPath, + }); + if (!run?.runId) { + throw new Error("Expected a login run"); + } + expect( + await finishAutostartLoginRun({ runId: run.runId, registryPath }) + ).toBe(true); + expect( + await finishAutostartLoginRun({ runId: run.runId, registryPath }) + ).toBe(false); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + pending: [], + started: [], + failed: [], + }); + expect((await read())?.autostart?.lastAttemptBootId).toBeNull(); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).not.toBeNull(); +}); + +test("invalid global policy fails closed, survives unrelated writes and explicit resume repairs it", async () => { + const raw = { paused: "unknown", futureGlobal: [1, 2] }; + const registry = await readProjectsRegistry({ registryPath }); + await writeFile( + registryPath, + JSON.stringify({ ...registry, autostartGlobal: raw }) + ); + expect(await readAutostartGlobal({ registryPath })).toMatchObject({ + paused: true, + invalid: true, + }); + await setProjectAutostart({ id: "alpha", registryPath }); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); + await upsertProjectRegistration({ project: await createTrustProject() }); + expect( + JSON.parse(await readFile(registryPath, "utf8")).autostartGlobal + ).toEqual(raw); + await setAutostartGlobalPaused({ paused: false, registryPath }); + expect(await readAutostartGlobal({ registryPath })).toEqual( + getDefaultAutostartGlobal() + ); +}); + +test("global mutations and reads require trusted files even without project markers", async () => { + await setAutostartGlobalPaused({ paused: false, registryPath }); + await chmod(registryPath, 0o664); + const before = await readFile(registryPath, "utf8"); + await expect(readAutostartGlobal({ registryPath })).rejects.toMatchObject({ + code: "E_PERMISSION", + }); + await expect( + setAutostartGlobalPaused({ paused: true, registryPath }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await expect( + beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + pendingIds: [], + registryPath, + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await expect( + upsertProjectRegistration({ project: await createTrustProject() }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(await readFile(registryPath, "utf8")).toBe(before); +}); + +test("progress updates preserve a concurrent global pause and ordinary registry refresh", async () => { + const run = await beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + pendingIds: ["alpha", "beta"], + registryPath, + }); + if (!run?.runId) { + throw new Error("Expected a login run"); + } + const project = await createTrustProject(); + await Promise.all([ + recordAutostartLoginOutcome({ + runId: run.runId, + id: "alpha", + succeeded: true, + registryPath, + }), + setAutostartGlobalPaused({ paused: true, registryPath }), + upsertProjectRegistration({ project }), + ]); + expect(await readAutostartGlobal({ registryPath })).toMatchObject({ + paused: true, + loginRun: { pending: ["beta"], started: ["alpha"] }, + }); + await finishAutostartLoginRun({ runId: run.runId, registryPath }); + expect(await readAutostartGlobal({ registryPath })).toMatchObject({ + paused: true, + loginRun: { inProgress: false, failed: [] }, + }); +}); + +test("login progress rejects oversized ID sets without rewriting state", async () => { + const before = await readFile(registryPath, "utf8"); + await expect( + beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + registryPath, + pendingIds: Array.from( + { length: AUTOSTART_LOGIN_PROJECT_LIMIT + 1 }, + (_, index) => `project-${index}` + ), + }) + ).rejects.toMatchObject({ code: "E_CONFIG_INVALID" }); + await expect( + beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + registryPath, + pendingIds: ["x".repeat(129)], + }) + ).rejects.toMatchObject({ code: "E_CONFIG_INVALID" }); + expect(await readFile(registryPath, "utf8")).toBe(before); +}); + +test("stored login arrays exceeding the progress bound globally pause automatic starts", async () => { + const global = getDefaultAutostartGlobal(); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [entry("alpha")], + autostartGlobal: { + ...global, + loginRun: { + ...global.loginRun, + pending: Array.from( + { length: AUTOSTART_LOGIN_PROJECT_LIMIT }, + (_, index) => `project-${index}` + ), + started: ["extra"], + }, + }, + }) + ); + expect(await readAutostartGlobal({ registryPath })).toMatchObject({ + paused: true, + invalid: true, + }); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 38c19b0b5..8a7b2bfb2 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -4,6 +4,10 @@ import { HACK_PROJECT_DIR_PRIMARY } from "../src/constants.ts"; import type { ProjectMeta } from "../src/lib/project-meta.ts"; import type { ProjectView } from "../src/lib/project-views.ts"; import type { RegisteredProject } from "../src/lib/projects-registry.ts"; +import { + type AutostartGlobal, + getDefaultAutostartGlobal, +} from "../src/lib/projects-registry.ts"; import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; @@ -804,3 +808,104 @@ test("summary payload keeps project identity and omits detailed runtime payloads "auto_register_ms" ); }); + +test("project polling observes fresh autostart progress without a runtime refresh", async () => { + const project: RegisteredProject = { + id: "example-id", + name: "example", + repoRoot: "/example/project", + projectDir: "/example/project/.hack", + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + autostart: { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: "private-boot-id", + }, + }; + let global: AutostartGlobal = { + ...getDefaultAutostartGlobal(), + loginRun: { + runId: "private-run-id", + bootId: "private-boot-id", + inProgress: true, + startedAt: "2026-01-01T00:00:00Z", + pending: [project.id], + started: [], + failed: [], + }, + }; + let ready = true; + const view: ProjectView = { + projectId: project.id, + name: project.name, + devHost: null, + repoRoot: project.repoRoot, + projectDir: project.projectDir, + ownership: null, + definedServices: [], + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: true, + runtimeStatus: "stopped", + runtime: null, + branchRuntime: [], + sessions: [], + lifecycle: null, + worktrees: null, + kind: "registered", + status: "stopped", + }; + const cache = createRuntimeCache({ + deps: { + readProjectsRegistry: async () => ({ + version: 1, + projects: [project], + autostartGlobal: global, + }), + readAutostartServiceReady: async () => ready, + buildProjectViews: async () => [view], + }, + }); + const options = { + filter: null, + includeGlobal: false, + includeUnregistered: false, + includeMeta: false, + }; + const first = await cache.getProjectsPayload(options); + expect(first.projects[0]?.autostart).toMatchObject({ + enabled: true, + state: "starting", + }); + expect(first.autostart_global).toMatchObject({ + service_ready: true, + login_run: { in_progress: true, pending: [project.id] }, + }); + const runtimeReads = runtimeReadCalls.length; + global = { + paused: true, + loginRun: { + ...global.loginRun, + inProgress: false, + pending: [], + started: [project.id], + }, + }; + ready = false; + const second = await cache.getProjectsPayload({ ...options, summary: true }); + expect(runtimeReadCalls.length).toBe(runtimeReads); + expect(second.projects[0]?.autostart).toMatchObject({ + enabled: true, + state: "paused", + paused_reason: null, + }); + expect(second.autostart_global).toMatchObject({ + paused: true, + service_ready: false, + login_run: { in_progress: false, pending: [], started: [project.id] }, + }); + expect(JSON.stringify(second)).not.toContain("private-"); +}); diff --git a/tests/status-native.test.ts b/tests/status-native.test.ts index a35b4443f..076c9dacb 100644 --- a/tests/status-native.test.ts +++ b/tests/status-native.test.ts @@ -6,7 +6,12 @@ import type { NativeProjectPsResult } from "../src/backends/native-project-obser import { resolveNativeRuntimeSelection } from "../src/backends/native-runtime-client.ts"; import { CliUsageError } from "../src/cli/command.ts"; import { __testOnlyProjectsCommand } from "../src/commands/projects.ts"; +import { + serializeAutostartGlobal, + serializeProjectAutostart, +} from "../src/lib/autostart-view.ts"; import { ProjectInputSelectionError } from "../src/lib/project-input-selection.ts"; +import { getDefaultAutostartGlobal } from "../src/lib/projects-registry.ts"; const { runNativeStatus } = __testOnlyProjectsCommand; const roots: string[] = []; @@ -70,6 +75,7 @@ test("hack status under native selection observes the cwd project like native ps const scopes: unknown[] = []; const out = await captureStdout(async () => { const code = await runNativeStatus({ + serviceReady: async () => false, readRegistry: async () => ({ version: 1, projects: [ @@ -120,12 +126,29 @@ test("hack status under native selection observes the cwd project like native ps // The JSON envelope matches native `hack ps --json`; nothing reports "stopped". const parsed = JSON.parse(out) as { ok: boolean; - data: NativeProjectPsResult & { readonly autostart: unknown }; + data: NativeProjectPsResult & { + readonly autostart: unknown; + readonly autostart_global: unknown; + }; }; expect(parsed.ok).toBe(true); expect(parsed.data).toEqual({ ...observed, + autostart_global: serializeAutostartGlobal( + getDefaultAutostartGlobal(), + false + ), autostart: { + enabled: true, + state: "paused", + paused_reason: "manual_stop", + failures: { + count: 0, + last_at: null, + failed_services: [], + last_error_code: null, + }, + max_failures: 3, branch: null, paused: true, last_result: { @@ -146,6 +169,7 @@ test("hack status under native selection refuses outside a project instead of li let observations = 0; await expect( runNativeStatus({ + serviceReady: async () => false, readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native: { binary: "/opt/candidate/hack-native", home: root }, @@ -160,6 +184,7 @@ test("hack status under native selection refuses outside a project instead of li ).rejects.toBeInstanceOf(CliUsageError); await expect( runNativeStatus({ + serviceReady: async () => false, readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native: { binary: "/opt/candidate/hack-native", home: root }, @@ -191,13 +216,20 @@ test("hack status under native selection dispatches a native-authored project li const dispatched: unknown[] = []; let observations = 0; const code = await runNativeStatus({ + serviceReady: async () => false, readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native, projectOpt: undefined, json: true, authored: async (input) => { - expect(await input.statusMetadata?.()).toEqual({ autostart: null }); + expect(await input.statusMetadata?.()).toEqual({ + autostart: serializeProjectAutostart(undefined), + autostart_global: serializeAutostartGlobal( + getDefaultAutostartGlobal(), + false + ), + }); dispatched.push(input); return 7; }, @@ -221,6 +253,7 @@ test("hack status under native selection dispatches a native-authored project li // Control: without the authored dispatch this checkout is refused before any observation. await expect( runNativeStatus({ + serviceReady: async () => false, readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native, From 08cf57d4864c9d885f456050e47e763d86e9619c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:57:53 +0000 Subject: [PATCH 05/44] fix(autostart): serialize login starts with explicit stops --- docs/always-on-projects.md | 67 +++- docs/diagrams/autostart-login.mmd | 43 ++- docs/diagrams/autostart-login.svg | 4 +- docs/reference/cli.md | 21 ++ docs/reference/v5-model-coverage.md | 2 +- scripts/lib/tla-runtime-models.ts | 15 + src/commands/autostart.ts | 82 ++++- src/commands/daemon.ts | 27 ++ src/commands/project-autostart-intent.ts | 134 ++++++- src/commands/project.ts | 61 +++- src/commands/projects.ts | 6 +- src/control-plane/sdk/config.ts | 11 +- src/daemon/autostart.ts | 328 ++++++++++++++---- src/daemon/launchd.ts | 4 + src/daemon/login-autostart.ts | 67 ++++ src/daemon/server.ts | 78 ++++- src/daemon/spawn.ts | 1 + src/daemon/systemd.ts | 9 +- src/lib/autostart-project-lock.ts | 95 +++++ src/lib/autostart-view.ts | 5 +- src/lib/child-environment.ts | 7 +- src/lib/projects-registry.ts | 141 +++++++- src/templates.ts | 6 + tests/autostart-cli.test.ts | 82 ++++- tests/autostart-lifecycle-intent.test.ts | 127 ++++++- tests/autostart-project-lock.test.ts | 176 ++++++++++ tests/autostart-view.test.ts | 15 + tests/child-environment.test.ts | 21 +- tests/control-plane-config.test.ts | 53 +++ tests/daemon-autostart.test.ts | 257 +++++++++++++- tests/daemon-login-autostart.test.ts | 148 ++++++++ tests/daemon-systemd.test.ts | 2 + tests/launchd.test.ts | 6 + .../tla/autostart-start-stop/COVERAGE.md | 46 +++ .../tla/autostart-start-stop/StartStop.tla | 128 +++++++ .../tla/autostart-start-stop/negative.cfg | 4 + .../tla/autostart-start-stop/positive.cfg | 4 + tests/project-autostart.test.ts | 181 ++++++++++ tests/runtime-cache.test.ts | 1 + 39 files changed, 2312 insertions(+), 153 deletions(-) create mode 100644 src/daemon/login-autostart.ts create mode 100644 src/lib/autostart-project-lock.ts create mode 100644 tests/autostart-project-lock.test.ts create mode 100644 tests/daemon-login-autostart.test.ts create mode 100644 tests/models/tla/autostart-start-stop/COVERAGE.md create mode 100644 tests/models/tla/autostart-start-stop/StartStop.tla create mode 100644 tests/models/tla/autostart-start-stop/negative.cfg create mode 100644 tests/models/tla/autostart-start-stop/positive.cfg diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 25cb8d78c..b555ce542 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -1,6 +1,6 @@ # Always-on projects -Autostart is a choice for this machine. A repository cannot enable it. Install +Autostart is a choice for this machine. Project configuration cannot enable it. Install Hack's per-user login service, then mark a registered project: ```sh @@ -18,7 +18,10 @@ a branch instance uses the primary checkout's code, not a different Git checkout changes the marker; it does not start anything immediately. `hack down` pauses autostart for the selected marked instance before attempting to -stop it. A later `hack up` clears the pause. Pausing does not stop running services: +stop it. Start and stop serialize on a machine-local instance lock: `down` waits +for an in-flight login start, then stops it before returning success. The login +child checks trusted intent under that lock and immediately before starting +containers. A later `hack up` clears the pause. Pausing does not stop running services: ```sh hack autostart pause --project sample @@ -39,16 +42,27 @@ projects paused by a manual `down`; start one with `hack up` or explicitly ![Login startup sequence](diagrams/autostart-login.svg) -The pass runs at the first `hackd` start in each OS boot. The installed service -normally starts it at login; starting the daemon manually or through a -daemon-backed CLI command can also trigger it. This slice supports Compose -projects on Linux and macOS. Native-runtime startup is skipped as -`native_unsupported` before probing Docker. - -The daemon waits up to 60 seconds for Docker, starts global routing only if it is -already installed, then considers unpaused projects in name/ID order. Starts are -at least 500 milliseconds apart, with at most two in progress. Each uses the -running daemon's CLI executable and the ordinary `up -d --no-interactive` path. +Only a daemon launched by the installed login service runs the automatic pass. +A daemon started manually or implicitly by another CLI command does not replay +projects. To run the pass deliberately, use `hack autostart run --no-interactive` +(optionally `--json`). Both entry points respect the same per-boot claims. +This slice supports Compose projects on Linux and macOS. Native-runtime startup +is skipped as `native_unsupported` before probing Docker. + +The daemon waits up to ten minutes for Docker, backing off from two seconds to +thirty seconds between probes. Configure the service bound with +`hack config set --global controlPlane.daemon.autostartEngineWaitMs 900000` +(1–3600000 milliseconds; restart the daemon to apply); an explicit +run also accepts `--engine-wait-ms`. While waiting, JSON reports +`login_run.waiting_for_engine: true`. An engine timeout consumes no project +claims. The service daemon retries when its regular engine observation detects +Docker later in the same boot. Global routing starts only if already installed, +then unpaused projects are considered in name/ID order. Starts are at least +500 milliseconds apart, with at most two in progress. Each uses the running +daemon's CLI executable and the ordinary `up -d --no-interactive` path. +Children receive a fixed environment allow-list and fixed executable search +path; launching-shell `DOCKER_*`, `COMPOSE_*` and `HACK_*` overrides are not copied. +Hack adds only its explicit internal noninteractive and identity settings. Already-running instances are adopted without repeating startup hooks. An unknown runtime state is skipped rather than taken as proof that the instance is down. Adoption requires a running container. An exited-zero container is treated as a @@ -66,7 +80,11 @@ recorded result. Run `hack up` manually to investigate and recover. The daemon d not install routing, start Docker, sign in, or migrate configuration at login. Before trusting intent, Hack opens `projects.json` without following links and requires a regular file owned by the current user, with no group or other write -access. That check repeats at every autostart read and mutation. Unsafe registry +access. On first opt-in, an unmarked legacy registry that is a regular file owned +by the user is atomically replaced with a fresh `0600` file, with a one-line +notice. The old inode is never reused, so an already-open writer cannot change +the replacement. This migration refuses symlinks, foreign ownership, malformed +registries and preexisting intent in an unsafe file. That check repeats at every autostart read and mutation. Unsafe registry permissions refuse startup. Do not repair a formerly writable registry with in-place `chmod`: another user's already-open writer descriptor remains writable. Preserve the old file outside the active registry, start with a fresh registry, @@ -81,8 +99,9 @@ the same boot does not repeat starts. A crash after the claim can leave an interrupted attempt; its outcome is unknown, so this slice requires manual `hack up` recovery. A new boot permits a new attempt. Resume permits the next login attempt; use `hack up` to start immediately. -An engine timeout or routing failure also consumes that boot's attempt. If Docker -is slow to become ready, start the projects with `hack up` after it is ready. +A routing failure consumes that boot's attempt; an engine timeout does not. +A start has a 120-second bound, then receives TERM and a two-second grace before +KILL. Partial startup remains possible; inspect and recover with `hack up`. `HACK_DAEMON_AUTOSTART_DISABLE=1` disables this pass as well as implicit daemon startup. Test harnesses and the isolated `hack-v5` launcher set it; those runs do @@ -90,7 +109,11 @@ not start marked projects. Use the installed CLI/service for login startup. This first slice starts projects once. It does not restart a project after failure or replace terminal-based lifecycle supervision. Persistent host helpers retain -the existing [lifecycle](lifecycle.md) behavior. Linux's systemd user service starts +the existing [lifecycle](lifecycle.md) behavior. On Linux, service-origin starts +run in independent transient user scopes, so their host helpers survive daemon +stop/restart while the daemon retains `KillMode=control-group`. Failure to create +a scope refuses the start rather than falling back into the daemon cgroup. +Linux's systemd user service starts at login; startup before login requires separately opting into user lingering. No system service or `sudo` is needed for Hack's login service. @@ -129,7 +152,8 @@ payloads include the same top-level `autostart_global` object: "started_at": null, "pending": [], "started": [], - "failed": [] + "failed": [], + "waiting_for_engine": false }, "service_ready": false } @@ -148,7 +172,8 @@ the same fields for failure counts and `needs_attention` after exhausting retrie Global `paused` is an independent machine setting. Login-run arrays contain stable project IDs: pending includes queued and active attempts, started includes started and already-running projects, and failed includes real startup failures -and refusals such as an engine timeout or missing key. Manual/global pauses, +and refusals such as a missing key. Engine waiting and timeout leave the queue +unclaimed and do not count as failures. Manual/global pauses, cancellation and target changes leave the queue without being marked failed. An interrupted run is closed before the next daemon startup; it does not authorize repeating a claimed start. `started_at` is `null` before the first @@ -185,3 +210,9 @@ this version rather than enabling startup. `hack doctor` warns when a project is marked but no login-service file exists and names `hack daemon install`. File presence alone does not prove a real login run or that the operating system enabled the service. + +Older Hack versions sharing this machine registry can drop markers and global +policy on their next write. This fails closed but loses opt-ins; avoid mixing +versions until the upgrade-handling slice supplies compatibility diagnostics. +The service-presence check also cannot yet prove its executable supports +autostart; reinstall the service with this CLI after upgrading. diff --git a/docs/diagrams/autostart-login.mmd b/docs/diagrams/autostart-login.mmd index 642757a83..3ec23f77c 100644 --- a/docs/diagrams/autostart-login.mmd +++ b/docs/diagrams/autostart-login.mmd @@ -8,26 +8,37 @@ sequenceDiagram participant Engine participant Registry participant CLI + participant User Login->>Daemon: Start user service Daemon->>Registry: Read machine markers alt Globally paused Daemon->>Daemon: Skip automatic starts else Automatic starts enabled - Daemon->>Registry: Begin login progress - Daemon->>Engine: Wait up to 60 seconds - opt Routing already installed - Daemon->>CLI: global up, no interaction - end - loop Unpaused projects in stable order - Daemon->>Registry: Claim once per boot - Daemon->>Engine: Inspect selected instance - alt Confirmed stopped - Daemon->>CLI: up -d, no interaction - CLI-->>Daemon: Result without prompts - else Running or unknown - Daemon->>Daemon: Adopt or skip + Daemon->>Registry: Begin login progress + Daemon->>Engine: Backoff wait, ten-minute bound + alt Engine unavailable + Daemon->>Registry: Waiting, no boot claims + Engine-->>Daemon: Later healthy observation end - Daemon->>Registry: Save result and progress - end - Daemon->>Registry: Finish login progress + opt Routing already installed + Daemon->>CLI: global up, no interaction + end + loop Unpaused projects in stable order + Daemon->>Registry: Claim once per boot + Daemon->>Engine: Inspect required services + alt Confirmed stopped + Daemon->>CLI: up -d in independent Linux scope + CLI->>Registry: Lock instance, recheck intent + CLI->>Registry: Recheck before containers start + CLI-->>Daemon: Result without prompts + else Running or unknown + Daemon->>Daemon: Adopt or skip + end + Daemon->>Registry: Save result and progress + end + Daemon->>Registry: Finish login progress end + User->>Registry: down publishes pause + User->>CLI: Wait for instance lock + CLI-->>User: Earlier startup settles + User->>Engine: Stop project under lock diff --git a/docs/diagrams/autostart-login.svg b/docs/diagrams/autostart-login.svg index 8e0d7225f..84540a9ef 100644 --- a/docs/diagrams/autostart-login.svg +++ b/docs/diagrams/autostart-login.svg @@ -1,4 +1,4 @@ -Autostart at loginopt[Routing already installed][Running or unknown]alt[Confirmed stopped]loop[Unpaused projects in stable order][Automatic starts enabled]alt[Globally paused]12345678910111213Start user serviceRead machine markersSkip automatic startsBegin login progressWait up to 60 secondsglobal up, no interactionClaim once per bootInspect selected instanceup -d, no interactionResult without promptsAdopt or skipSave result and progressFinish login progressLoginDaemonEngineRegistryCLILoginDaemonEngineRegistryCLI \ No newline at end of file +]]>Autostart at loginalt[Engine unavailable]opt[Routing already installed][Running or unknown]alt[Confirmed stopped]loop[Unpaused projects in stable order][Automatic starts enabled]alt[Globally paused]123456789101112131415161718192021Start user serviceRead machine markersSkip automatic startsBegin login progressBackoff wait, ten-minute boundWaiting, no boot claimsLater healthy observationglobal up, no interactionClaim once per bootInspect required servicesup -d in independent Linux scopeLock instance, recheck intentRecheck before containers startResult without promptsAdopt or skipSave result and progressFinish login progressdown publishes pauseWait for instance lockEarlier startup settlesStop project under lockLoginDaemonEngineRegistryCLIUserLoginDaemonEngineRegistryCLIUser \ No newline at end of file diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 03164c755..7cc9de966 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -27,6 +27,7 @@ hack autostart [options] | `hack autostart list` | List this machine's always-on projects | | `hack autostart pause [project]` | Pause one marked project, or all marked projects | | `hack autostart resume [project]` | Resume one marked project, or all marked projects | +| `hack autostart run` | Intentionally run one non-interactive autostart pass | ### Options @@ -160,6 +161,26 @@ hack autostart resume [project] [options] | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack autostart run` + +Intentionally run one non-interactive autostart pass + +### Usage + +```bash +hack autostart run [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--json` | Output JSON (machine-readable) | +| `--engine-wait-ms ` | Engine readiness wait bound (1-3600000 ms; default 600000) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + ## `hack project` diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 05816f859..203ab9eed 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -36,7 +36,7 @@ must be attached to the eventual exact integration revision. | Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification | | --- | --- | --- | --- | -| Machine autostart intent, pause, per-boot claim and startup replay | No TLA+ model in the once-per-login slice; registry lock is the atomic claim boundary | `projects-registry.ts` persists claim before dispatch; `daemon/autostart.ts` rechecks pause and target after observation. `project-autostart.test.ts`, `autostart-lifecycle-intent.test.ts` and `daemon-autostart.test.ts` cover old registries, pause/readback, concurrent claims, restart suppression, stable order, stagger and cap | At most two starts; 60-second engine wait; one claim per project/boot. Crash after claim may skip an attempt. A pause after the final recheck can overlap an already-dispatched start; no distributed cancellation proof. Real Linux/macOS login, kernel scheduling, keychain behavior and terminal-independent lifecycle supervision remain external qualification. | +| Machine autostart intent, pause, per-boot claim and startup replay | [Start/stop lock model](../../tests/models/tla/autostart-start-stop/COVERAGE.md): a completed down prevents a prior login child from starting; negative control releases the lock early | `projects-registry.ts` persists claims before dispatch; login CLI holds the instance lock and rechecks trusted intent under it and before container publication; `down` pauses before waiting and stops under that lock. `autostart-lifecycle-intent.test.ts` holds a fake start across real source `down`; `autostart-project-lock.test.ts` covers target/pause guards and scope serialization. Daemon regressions cover delayed engine readiness without consumed claims, stable order, stagger, cap and service-only dispatch | One start child and one down, 41 distinct model states; at most two dispatched starts, configurable ten-minute engine wait, one claim per project/boot. Model assumes an effect-capable child retains its lock through settlement; it does not prove OS orphan cleanup, Docker's asynchronous engine work or filesystem durability. Real isolated Linux user-manager helper survival across service restart passed; real macOS login, cold engine and keychain behavior remain external qualification. | | Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal, restore-before-busy-retirement and run-scoped cleanup-report tests, and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance; receipt lock timing and terminal restoration are regression-covered, not modeled. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. | | Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. | | Native status across startup lease stages; failed start with running services (HACK-2110) | [native-status-start](../../tests/models/tla/native-status-start/COVERAGE.md) | Observer-only presentation, stable journal fence, distinct authored Observe/Status replies; real-flock held-reader start test and expiry diagnostic; plain-down exact-proof recovery tests. Failure capture occurs at startup/effect verification and owned cleanup, never status; first failure wins, incomplete-rebind capture and owner-crash reconstruction are excluded | Seven acquisition stages, one reader, one start; 110 states. Negative control fails after services start. Second-mutator refusal and complete recovery proof are assumptions checked separately in code, not independently proved by this model. No new control-loop tick or lease; cleanup settle is bounded at ten seconds per exited listener. Lead Mac gate: ps every 0.5 s across ten starts, cross-project polling and failed-start plain down. | diff --git a/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index f6c6e52f9..e0e6614dd 100644 --- a/scripts/lib/tla-runtime-models.ts +++ b/scripts/lib/tla-runtime-models.ts @@ -141,6 +141,21 @@ const restartPolicyStaleUses = [ { engine: "exited", bound: 3 }, ] as const; const contracts: readonly ModelContract[] = [ + { + name: "autostart-start-stop", + module: "StartStop", + states: 41, + invariant: "NoStartAfterSuccessfulDown", + action: "ChildStart", + fields: [ + 'holder = "none"', + "paused = TRUE", + 'child = "started"', + "running = TRUE", + 'down = "done"', + "lateStart = TRUE", + ], + }, { name: "interactive-session", module: "Session", diff --git a/src/commands/autostart.ts b/src/commands/autostart.ts index 05e7dc715..d48199f0f 100644 --- a/src/commands/autostart.ts +++ b/src/commands/autostart.ts @@ -4,6 +4,7 @@ import { CliUsageError, type CommandHandlerFor, defineCommand, + defineOption, withHandler, } from "../cli/command.ts"; import { optBranch, optJson, optProject } from "../cli/options.ts"; @@ -11,13 +12,19 @@ import { parseProjectSelectorOption, unknownProjectMessage, } from "../cli/project-selector.ts"; +import { + resolveAutostartEngineWaitMs, + runAutostartAtLogin, +} from "../daemon/autostart.ts"; import { projectAutostartFromGlobal, readAutostartServiceReady, serializeAutostartGlobal, } from "../lib/autostart-view.ts"; import { emitCliResult, okResult } from "../lib/cli-result.ts"; +import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; +import { resolveUserHomeDir } from "../lib/home-dir.ts"; import { sanitizeBranchSlug } from "../lib/project.ts"; import { PROJECT_SELECTOR_PATTERN } from "../lib/project-name.ts"; import { @@ -65,6 +72,21 @@ const resumeSpec = defineCommand({ positionals: projectPositionals, subcommands: [], } as const); +const optEngineWaitMs = defineOption({ + name: "engineWaitMs", + type: "number", + long: "--engine-wait-ms", + valueHint: "", + description: "Engine readiness wait bound (1-3600000 ms; default 600000)", +} as const); +const runSpec = defineCommand({ + name: "run", + summary: "Intentionally run one non-interactive autostart pass", + group: "Project", + options: [optJson, optEngineWaitMs], + positionals: [], + subcommands: [], +} as const); const listSpec = defineCommand({ name: "list", summary: "List this machine's always-on projects", @@ -193,7 +215,14 @@ const add: CommandHandlerFor = async ({ ctx, args }) => { "Invalid --branch value; use a branch alias containing letters or digits." ); } - const updated = await setProjectAutostart({ id: project.id, branch }); + const updated = await setProjectAutostart({ + id: project.id, + branch, + onPermissionsRepaired: () => + process.stderr.write( + "Repaired this machine's unmarked projects registry permissions to 0600 before enabling autostart.\n" + ), + }); await writeMutation({ json: args.options.json, action: "add", @@ -322,6 +351,56 @@ const list: CommandHandlerFor = async ({ args }) => { return 0; }; +const run: CommandHandlerFor = async ({ args }) => { + let engineWaitMs: number; + try { + engineWaitMs = resolveAutostartEngineWaitMs(args.options.engineWaitMs); + } catch { + throw new CliUsageError( + "--engine-wait-ms must be an integer from 1 to 3600000." + ); + } + const result = await runAutostartAtLogin({ + engineWaitMs, + runtime: { + home: resolveUserHomeDir(), + hackHome: resolveGlobalHackDir(), + serviceOrigin: false, + platform: process.platform, + }, + }); + const registry = await readProjectsRegistry(); + if (args.options.json) { + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); + emitCliResult({ + result: okResult({ + data: { + action: "run", + waiting_for_engine: result.waitingForEngine, + autostart_global: global, + projects: registry.projects + .filter((project) => project.autostart) + .map((project) => ({ + project_id: project.id, + name: project.name, + autostart: projectAutostartFromGlobal({ project, global }), + })), + }, + }), + }); + } else { + process.stdout.write( + result.waitingForEngine + ? "Autostart is waiting for the engine; no project start was claimed. Retry with: hack autostart run --no-interactive\n" + : "Autostart pass finished. Already attempted projects were not replayed; use hack up -d for manual recovery.\n" + ); + } + return 0; +}; + export const autostartCommand = defineCommand({ name: "autostart", summary: "Manage always-on projects on this machine", @@ -334,5 +413,6 @@ export const autostartCommand = defineCommand({ withHandler(listSpec, list), withHandler(pauseSpec, pause), withHandler(resumeSpec, resume), + withHandler(runSpec, run), ], } as const); diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 482bb2616..0aac54756 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -19,6 +19,7 @@ import { resolveLaunchdAccess, uninstallLaunchdService, } from "../daemon/launchd.ts"; +import { isLoginServiceAutostart } from "../daemon/login-autostart.ts"; import { type DaemonPaths, resolveDaemonPaths } from "../daemon/paths.ts"; import { type DaemonProcessRefusal, @@ -51,6 +52,7 @@ import { HackCliError } from "../lib/cli-result.ts"; import { updateGlobalConfig } from "../lib/config.ts"; import { readTextFile } from "../lib/fs.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; +import { readProjectsRegistry } from "../lib/projects-registry.ts"; import { buildDockerStatusProbe, detectDockerBackend, @@ -297,6 +299,7 @@ async function handleDaemonStart({ } if (args.options.foreground) { + await reportManualAutostartSkip(); await runDaemon({ paths, foreground: true }); return 0; } @@ -306,9 +309,33 @@ async function handleDaemonStart({ return viaLaunchd; } + await reportManualAutostartSkip(); return await startDaemonDetached({ paths }); } +/** A manual cache daemon start does not acquire authority to start projects. */ +export async function reportManualAutostartSkip(opts?: { + readonly env?: Readonly>; + readonly readRegistry?: typeof readProjectsRegistry; + readonly note?: (message: string) => void; +}): Promise { + if (isLoginServiceAutostart({ env: opts?.env ?? process.env })) { + return; + } + const registry = await (opts?.readRegistry ?? readProjectsRegistry)().catch( + () => null + ); + if (registry?.projects.some((project) => project.autostart)) { + const message = + "This manual daemon launch did not run the login autostart pass. Run it intentionally with: hack autostart run --no-interactive"; + if (opts?.note) { + opts.note(message); + } else { + logger.info({ message }); + } + } +} + /** * When launchd manages the daemon, start through launchd. Returns null when * no launchd service is loaded (unchanged macOS behavior, moved out of diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 6085a29d1..cf4aa2ca4 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -1,12 +1,26 @@ +import { realpath } from "node:fs/promises"; import { resolve } from "node:path"; import { CliUsageError } from "../cli/command.ts"; +import { parseProjectSelectorOption } from "../cli/project-selector.ts"; import { PROJECT_ENV_CONTRACT_FILENAME, PROJECT_ENV_FILENAME, } from "../constants.ts"; +import { + assertAutostartProjectCurrent, + withAutostartProjectLock, +} from "../lib/autostart-project-lock.ts"; +import { resolveEffectiveBranch } from "../lib/branches.ts"; import { HackCliError } from "../lib/cli-result.ts"; +import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { pathExists } from "../lib/fs.ts"; -import { type ProjectContext, sanitizeBranchSlug } from "../lib/project.ts"; +import { + findProjectContextAtRoot, + type ProjectContext, + readProjectConfig, + resolveWorktreeAutoBranch, + sanitizeBranchSlug, +} from "../lib/project.ts"; import { discoverComposeServiceNames, findUnmigratedLegacyDotenv, @@ -17,6 +31,7 @@ import { discoverProjectInputs } from "../lib/project-input-selection.ts"; import { normalizeProjectName } from "../lib/project-name.ts"; import { type RegisteredProject, + readProjectsRegistry, setProjectAutostartPaused, } from "../lib/projects-registry.ts"; @@ -114,3 +129,120 @@ export async function updateNativeAutostartIntent(opts: { } return true; } + +/** Resolve the same registered instance for command serialization without native legacy discovery. */ +export async function withAutostartCommandLock(opts: { + readonly cwd: string; + readonly path?: string; + readonly project?: string; + readonly branch?: string; + readonly run: () => Promise; +}): Promise { + const login = process.env.HACK_AUTOSTART_LOGIN === "1"; + const branch = opts.branch?.trim() + ? sanitizeBranchSlug(opts.branch.trim()) + : null; + const registry = await readProjectsRegistry( + login ? { requireAutostartTrust: true } : undefined + ); + const requested = resolve(opts.cwd, opts.path ?? "."); + const cwd = await realpath(requested).catch(() => requested); + const selected = + opts.project === undefined + ? undefined + : parseProjectSelectorOption({ projectOpt: opts.project }); + const registrations = registry.projects + .filter((project) => + selected + ? normalizeProjectName(project.name) === selected.name + : [ + project.repoRoot, + ...(project.worktrees?.map((tree) => tree.path) ?? []), + ].some((root) => cwd === root || cwd.startsWith(`${root}/`)) + ) + .sort((a, b) => b.repoRoot.length - a.repoRoot.length); + const registration = registrations[0]; + if (selected && !registration) { + return await opts.run(); + } + const inputs = await discoverProjectInputs({ + startDir: selected && registration ? registration.repoRoot : cwd, + }); + if (!inputs || inputs.kind !== "legacy") { + return await opts.run(); + } + const project = await findProjectContextAtRoot({ + projectRoot: inputs.projectRoot, + projectDirName: inputs.composeDirectories.includes(".hack") + ? ".hack" + : ".dev", + }); + if (!project) { + return await opts.run(); + } + const cfg = await readProjectConfig(project); + const effective = await resolveEffectiveBranch({ + explicitBranch: branch, + projectRoot: project.projectRoot, + autoBranchEnabled: login ? false : resolveWorktreeAutoBranch(cfg), + }); + const projectId = login + ? process.env.HACK_AUTOSTART_PROJECT_ID + : registration?.id; + if (login && (!projectId || projectId.length > 128)) { + throw new HackCliError({ + code: "E_CANCELLED", + message: "Autostart requires an explicit registered project identity.", + }); + } + if ( + login && + !registry.projects.some( + (entry) => + entry.id === projectId && entry.repoRoot === project.projectRoot + ) + ) { + throw new HackCliError({ + code: "E_CANCELLED", + message: "Autostart project path changed; no start ran.", + }); + } + const scope = { + projectId: projectId ?? "", + composeProject: await resolveComposeProjectName({ project, cfg }), + branch: effective.branch, + }; + return await withAutostartProjectLock({ + scope, + run: async () => { + if (login) { + await assertAutostartProjectCurrent({ + scope, + expectedRoot: project.projectRoot, + }); + } + return await opts.run(); + }, + }); +} + +/** Read the original login identity again at the container publication boundary. */ +export async function assertLoginAutostartCurrent( + branch: string | null, + expectedRoot: string +): Promise { + if (process.env.HACK_AUTOSTART_LOGIN !== "1") { + return; + } + const projectId = process.env.HACK_AUTOSTART_PROJECT_ID; + if (!projectId) { + throw new HackCliError({ + code: "E_CANCELLED", + message: "Autostart project identity is unavailable.", + }); + } + await assertAutostartProjectCurrent({ + scope: { projectId, branch }, + expectedRoot, + }); +} diff --git a/src/commands/project.ts b/src/commands/project.ts index 9924a8921..8b71cb273 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -426,8 +426,10 @@ import { displayNativePs } from "./native-ps-display.ts"; import { answerNativeAlreadyRunning } from "./native-up-already-running.ts"; import { assertAutostartEnvironment, + assertLoginAutostartCurrent, findAutostartRegistration, updateNativeAutostartIntent, + withAutostartCommandLock, } from "./project-autostart-intent.ts"; import { prepareDisposableCachePrune, @@ -6478,7 +6480,14 @@ async function updateAutostartIntent(opts: { "Project registration conflicts; inspect hack projects before enabling autostart." ); } - await setProjectAutostart({ id: registered.project.id, branch }); + await setProjectAutostart({ + id: registered.project.id, + branch, + onPermissionsRepaired: () => + process.stderr.write( + "Repaired this machine's unmarked projects registry permissions to 0600 before enabling autostart.\n" + ), + }); return; } const projectDir = await realpath(project.projectDir).catch( @@ -6500,7 +6509,35 @@ async function updateAutostartIntent(opts: { } } -async function handleUp({ +async function handleUp( + input: Parameters[0] +): Promise { + parseRequestedTarget({ raw: input.args.options.target }); + const login = process.env.HACK_AUTOSTART_LOGIN === "1"; + if (!login) { + await updateAutostartIntent({ ...input, action: "up" }); + } + const run = async () => { + if (login) { + await updateAutostartIntent({ ...input, action: "up" }); + } + return await handleUpUnlocked(input); + }; + // Foreground up owns the terminal until shutdown; its existing supervisor + // handles stopping. Never hold this startup lock for that whole lifetime. + if (!(login || input.args.options.detach || input.args.options.json)) { + return await run(); + } + return await withAutostartCommandLock({ + cwd: input.ctx.cwd, + path: input.args.options.path, + project: input.args.options.project, + branch: input.args.options.branch, + run, + }); +} + +async function handleUpUnlocked({ ctx, args, }: { @@ -6509,7 +6546,6 @@ async function handleUp({ }): Promise { // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); - await updateAutostartIntent({ ctx, args, action: "up" }); if (resolveNativeRuntimeSelection()) { // Once per `hack up`, whichever native start path follows (HACK-2026). noteNativeBackendBeta({ json: args.options.json === true }); @@ -6943,6 +6979,7 @@ async function runUpCommand({ up: (beforeStart) => composeRuntimeBackend.up({ beforeStart: async () => { + await assertLoginAutostartCurrent(branch, project.projectRoot); await beforeStart?.(); if (!detach) { // Compose owns foreground shutdown. Lifecycle cleanup runs after @@ -7246,7 +7283,22 @@ function refuseNativeDownOptions(options: DownArgs["options"]): void { } } -async function handleDown({ +async function handleDown( + input: Parameters[0] +): Promise { + parseRequestedTarget({ raw: input.args.options.target }); + // Publish stop intent before waiting for an earlier start to release its lock. + await updateAutostartIntent({ ...input, action: "down" }); + return await withAutostartCommandLock({ + cwd: input.ctx.cwd, + path: input.args.options.path, + project: input.args.options.project, + branch: input.args.options.branch, + run: () => handleDownUnlocked(input), + }); +} + +async function handleDownUnlocked({ ctx, args, deferFinalization, @@ -7260,7 +7312,6 @@ async function handleDown({ }): Promise { // Refuse a malformed --target before any project or runtime work. parseRequestedTarget({ raw: args.options.target }); - await updateAutostartIntent({ ctx, args, action: "down" }); const authored = await tryNativeComposeCommand({ cwd: ctx.cwd, path: args.options.path, diff --git a/src/commands/projects.ts b/src/commands/projects.ts index d28402d33..d65ffc232 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -749,7 +749,8 @@ async function runProjects(opts: { const autostartState = serializeProjectAutostart(p.autostart, { globallyPaused: global.paused, starting: - global.login_run.in_progress && + (global.login_run.in_progress || + global.login_run.waiting_for_engine) && p.projectId !== undefined && global.login_run.pending.includes(p.projectId), }).state; @@ -954,7 +955,8 @@ function outputProjectsJson(opts: { autostart: serializeProjectAutostart(view.autostart, { globallyPaused: opts.autostartGlobal.paused, starting: - opts.autostartGlobal.login_run.in_progress && + (opts.autostartGlobal.login_run.in_progress || + opts.autostartGlobal.login_run.waiting_for_engine) && view.projectId !== undefined && opts.autostartGlobal.login_run.pending.includes(view.projectId), }), diff --git a/src/control-plane/sdk/config.ts b/src/control-plane/sdk/config.ts index ece56e3cf..8ee2f21a1 100644 --- a/src/control-plane/sdk/config.ts +++ b/src/control-plane/sdk/config.ts @@ -77,11 +77,18 @@ const DaemonLaunchdConfigSchema = z.object({ }); const DaemonConfigInputSchema = z.object({ + autostartEngineWaitMs: z.number().int().min(1).max(3_600_000).optional(), autoStart: z.boolean().optional(), launchd: DaemonLaunchdConfigInputSchema.optional(), }); const DaemonConfigSchema = z.object({ + autostartEngineWaitMs: z + .number() + .int() + .min(1) + .max(3_600_000) + .default(600_000), autoStart: z.boolean().default(true), launchd: DaemonLaunchdConfigSchema.default( DaemonLaunchdConfigSchema.parse({}) @@ -408,6 +415,8 @@ export function createDefaultControlPlaneConfig(): ControlPlaneConfig { */ export async function readControlPlaneConfig(opts: { readonly projectDir?: string; + /** Explicit owned service path bypasses shell configuration overrides. */ + readonly globalConfigPath?: string; /** * Throw when a configuration file exists but cannot be read, instead of * treating it as empty (HACK-2165): for callers that must tell an absent @@ -416,7 +425,7 @@ export async function readControlPlaneConfig(opts: { readonly strict?: boolean; }): Promise { const globalLayer = await readControlPlaneLayer({ - path: resolveGlobalConfigPath(), + path: opts.globalConfigPath ?? resolveGlobalConfigPath(), label: "Global config", strict: opts.strict === true, }); diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index 8e78970b2..cc67d0989 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -1,14 +1,15 @@ -import { resolve } from "node:path"; +import { randomUUID } from "node:crypto"; +import { userInfo } from "node:os"; +import { isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { YAML } from "bun"; import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; import { GLOBAL_CADDY_COMPOSE_FILENAME, GLOBAL_CADDY_DIR_NAME, + GLOBAL_PROJECTS_REGISTRY_FILENAME, } from "../constants.ts"; -import { childEnvironment } from "../lib/child-environment.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; -import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; import { findProjectContextAtRoot, readProjectConfig } from "../lib/project.ts"; @@ -21,6 +22,7 @@ import { readProjectsRegistry, recordAutostartLoginOutcome, recordProjectAutostartResult, + setAutostartLoginWaitingForEngine, } from "../lib/projects-registry.ts"; export type AutostartResult = { @@ -37,7 +39,10 @@ export type AutostartDependencies = { readonly startupRefusal?: () => "native_unsupported" | null; readonly now: () => number; readonly sleep: (ms: number, signal?: AbortSignal) => Promise; - readonly engineReady: () => Promise; + readonly engineReady: (opts?: { + readonly timeoutMs: number; + readonly signal?: AbortSignal; + }) => Promise; readonly routingInstalled: () => Promise; readonly startRouting: (signal?: AbortSignal) => Promise; readonly inspect: ( @@ -52,11 +57,23 @@ export type AutostartDependencies = { const MAC_BOOT_TIME_PATTERN = /sec = (\d+), usec = (\d+)/; -const ENGINE_WAIT_MS = 60_000; +const ENGINE_WAIT_MS = 600_000; +const ENGINE_POLL_MAX_MS = 30_000; const ENGINE_POLL_MS = 2000; const START_STAGGER_MS = 500; const MAX_CONCURRENT_STARTS = 2; +/** Values come from the owned service's validated paths, never the invoking shell. */ +export type AutostartRuntimeOptions = { + readonly home: string; + readonly hackHome: string; + readonly engineWaitMs?: number; + readonly serviceOrigin?: boolean; + readonly platform?: NodeJS.Platform; +}; + +export type AutostartLoginOutcome = { readonly waitingForEngine: boolean }; + /** * One replay per OS boot, with a durable claim before each launch. An interrupted * claim is deliberately not retried in this first slice: unknown launch outcome @@ -67,9 +84,31 @@ export async function runAutostartAtLogin( readonly signal?: AbortSignal; readonly registryPath?: string; readonly dependencies?: AutostartDependencies; + readonly runtime?: AutostartRuntimeOptions; + readonly engineWaitMs?: number; } = {} -): Promise { - const deps = opts.dependencies ?? productionDependencies(); +): Promise { + const runtime = opts.runtime; + const deps = + opts.dependencies ?? + productionDependencies(requireAutostartRuntime(runtime)); + const registryPath = + opts.registryPath ?? + (runtime + ? resolve(runtime.hackHome, GLOBAL_PROJECTS_REGISTRY_FILENAME) + : undefined); + const options = { ...opts, registryPath }; + return await runAutostartLoginPass({ ...options, dependencies: deps }); +} + +async function runAutostartLoginPass(opts: { + readonly signal?: AbortSignal; + readonly registryPath?: string; + readonly dependencies: AutostartDependencies; + readonly runtime?: AutostartRuntimeOptions; + readonly engineWaitMs?: number; +}): Promise { + const deps = opts.dependencies; const prior = await readAutostartGlobal({ registryPath: opts.registryPath }); if (prior.loginRun.inProgress && prior.loginRun.runId) { await finishAutostartLoginRun({ @@ -82,7 +121,7 @@ export async function runAutostartAtLogin( requireAutostartTrust: true, }); if (registry.autostartGlobal?.paused) { - return; + return { waitingForEngine: false }; } const machineBootId = await deps.bootId().catch(() => null); const bootId = machineBootId ?? "unavailable"; @@ -99,23 +138,36 @@ export async function runAutostartAtLogin( a.name.localeCompare(b.name, "en") || a.id.localeCompare(b.id, "en") ); if (pending.length === 0 || opts.signal?.aborted) { - return; + return { waitingForEngine: false }; } const loginRun = await beginAutostartLoginRun({ bootId, startedAt: new Date(deps.now()).toISOString(), pendingIds: pending.map((project) => project.id), registryPath: opts.registryPath, + waitingForEngine: true, }); if (!loginRun?.runId) { - return; + return { waitingForEngine: false }; } + let waitingForEngine = false; try { const refusal = await prepareAutostartRuntime({ deps, machineBootId, signal: opts.signal, registryPath: opts.registryPath, + engineWaitMs: opts.engineWaitMs ?? opts.runtime?.engineWaitMs, + runId: loginRun.runId, + }); + if (refusal === "engine_timeout" && !opts.signal?.aborted) { + waitingForEngine = true; + return { waitingForEngine: true }; + } + await setAutostartLoginWaitingForEngine({ + runId: loginRun.runId, + waitingForEngine: false, + registryPath: opts.registryPath, }); await dispatchAutostartProjects({ ...opts, @@ -129,8 +181,10 @@ export async function runAutostartAtLogin( await finishAutostartLoginRun({ runId: loginRun.runId, registryPath: opts.registryPath, + waitingForEngine, }); } + return { waitingForEngine: false }; } async function dispatchAutostartProjects(opts: { @@ -206,6 +260,8 @@ async function dispatchAutostartProjects(opts: { async function prepareAutostartRuntime(opts: { readonly deps: AutostartDependencies; readonly machineBootId: string | null; + readonly runId: string; + readonly engineWaitMs?: number; readonly registryPath?: string; readonly signal?: AbortSignal; }): Promise { @@ -217,19 +273,27 @@ async function prepareAutostartRuntime(opts: { return "boot_identity_unavailable"; } const { deps, signal } = opts; - const deadline = deps.now() + ENGINE_WAIT_MS; + const deadline = deps.now() + resolveAutostartEngineWaitMs(opts.engineWaitMs); let ready = false; + let delay = ENGINE_POLL_MS; while (!signal?.aborted) { if ( (await readAutostartGlobal({ registryPath: opts.registryPath })).paused ) { return "global_paused"; } - ready = await deps.engineReady().catch(() => false); + const remaining = deadline - deps.now(); + if (remaining <= 0) { + break; + } + ready = await deps + .engineReady({ timeoutMs: Math.min(2000, remaining), signal }) + .catch(() => false); if (ready || deps.now() >= deadline) { break; } - await deps.sleep(Math.min(ENGINE_POLL_MS, deadline - deps.now()), signal); + await deps.sleep(Math.min(delay, deadline - deps.now()), signal); + delay = Math.min(delay * 2, ENGINE_POLL_MAX_MS); } if (!ready) { return "engine_timeout"; @@ -240,6 +304,11 @@ async function prepareAutostartRuntime(opts: { ) { return "global_paused"; } + await setAutostartLoginWaitingForEngine({ + runId: opts.runId, + waitingForEngine: false, + registryPath: opts.registryPath, + }); if (await deps.routingInstalled()) { if ( signal?.aborted || @@ -375,6 +444,14 @@ export function classifyAutostartExit(opts: { } catch { /* A malformed response is a failure, never a reason to retain output. */ } + if ( + code === "E_CANCELLED" && + isRecord(detail) && + typeof detail.reason === "string" && + ["paused", "global_paused", "target_changed"].includes(detail.reason) + ) { + return { status: "skipped", reason: detail.reason }; + } if ( code === "E_CONFIG_INVALID" && isRecord(detail) && @@ -387,11 +464,11 @@ export function classifyAutostartExit(opts: { if (code === "E_PROJECT_NOT_FOUND") { return { status: "skipped", reason: "project_unavailable" }; } - const skipReasons: Readonly> = { - E_INTERACTIVE_REQUIRED: "interactive_required", - E_ENV_KEY_MISSING: "env_key_missing", - }; - const reason = typeof code === "string" ? skipReasons[code] : undefined; + const skipReasons = new Map([ + ["E_INTERACTIVE_REQUIRED", "interactive_required"], + ["E_ENV_KEY_MISSING", "env_key_missing"], + ]); + const reason = typeof code === "string" ? skipReasons.get(code) : undefined; return reason ? { status: "skipped", reason } : { @@ -411,15 +488,21 @@ export function autostartRuntimeRefusal( } } -function productionDependencies(): AutostartDependencies { +function productionDependencies( + runtime: AutostartRuntimeOptions +): AutostartDependencies { + const env = autostartServiceEnvironment(runtime); const invocation = [ process.execPath, ...(Bun.main.startsWith("/$bunfs/") ? [] - : [fileURLToPath(new URL("../../index.ts", import.meta.url))]), + : [ + "--no-env-file", + fileURLToPath(new URL("../../index.ts", import.meta.url)), + ]), ]; return { - startupRefusal: autostartRuntimeRefusal, + startupRefusal: () => autostartRuntimeRefusal(env), now: Date.now, sleep: async (ms, signal) => { if (signal?.aborted) { @@ -435,21 +518,25 @@ function productionDependencies(): AutostartDependencies { signal?.addEventListener("abort", finish, { once: true }); }); }, - bootId: readMachineBootId, - engineReady: async () => + bootId: () => readMachineBootId(env), + engineReady: async (probe) => ( await runAutostartSubprocess( ["docker", "info", "--format", "{{.ID}}"], { - timeoutMs: 2000, + timeoutMs: probe?.timeoutMs ?? 2000, + terminationGraceMs: 0, + signal: probe?.signal, + env, } ) ).exitCode === 0, routingInstalled: async () => Bun.file( resolve( - resolveGlobalHackDir(), + runtime.hackHome, GLOBAL_CADDY_DIR_NAME, + GLOBAL_PROJECTS_REGISTRY_FILENAME, GLOBAL_CADDY_COMPOSE_FILENAME ) ).exists(), @@ -460,33 +547,70 @@ function productionDependencies(): AutostartDependencies { { timeoutMs: 60_000, signal, + env, } ) ).exitCode === 0, - inspect: inspectComposeProject, + inspect: (project, signal) => inspectComposeProject(project, signal, env), start: async (project, signal) => { - const args = [ - ...invocation, - "up", - "-d", - "--no-interactive", - "--json", - "--path", - project.repoRoot, - ]; - if (project.autostart?.branch) { - args.push(`--branch=${project.autostart.branch}`); - } + const args = buildAutostartProjectInvocation({ + invocation, + project, + serviceOrigin: runtime.serviceOrigin, + platform: runtime.platform, + }); return classifyAutostartExit( - await runAutostartSubprocess(args, { timeoutMs: 120_000, signal }) + await runAutostartSubprocess(args, { + timeoutMs: 120_000, + signal, + env, + projectId: project.id, + }) ); }, }; } +/** Linux login-service children own a separate scope so hackd replacement leaves helpers alive. */ +export function buildAutostartProjectInvocation(opts: { + readonly invocation: readonly string[]; + readonly project: RegisteredProject; + readonly serviceOrigin?: boolean; + readonly platform?: NodeJS.Platform; +}): readonly string[] { + const args = [ + ...opts.invocation, + "up", + "-d", + "--no-interactive", + "--json", + "--path", + opts.project.repoRoot, + ]; + if (opts.project.autostart?.branch) { + args.push(`--branch=${opts.project.autostart.branch}`); + } + if (opts.serviceOrigin && !opts.platform) { + throw new Error( + "A service-origin autostart requires its explicit platform." + ); + } + return opts.serviceOrigin && opts.platform === "linux" + ? [ + "systemd-run", + "--user", + "--scope", + "--quiet", + `--unit=hack-autostart-${randomUUID()}`, + ...args, + ] + : args; +} + async function inspectComposeProject( project: RegisteredProject, - signal?: AbortSignal + signal?: AbortSignal, + env: Readonly> = {} ): Promise<"running" | "stopped" | "unknown"> { const context = await findProjectContextAtRoot({ projectRoot: project.repoRoot, @@ -500,11 +624,7 @@ async function inspectComposeProject( return "unknown"; } // An uncertain selected graph must run ordinary up rather than adopt a subset. - if ( - project.autostart?.branch || - process.env.COMPOSE_FILE || - process.env.COMPOSE_PROFILES - ) { + if (project.autostart?.branch || env.COMPOSE_FILE || env.COMPOSE_PROFILES) { return "stopped"; } const model = await readAutostartComposeModel(context.composeFile); @@ -527,7 +647,7 @@ async function inspectComposeProject( "--format", "{{.ID}}", ], - { timeoutMs: 5000, signal } + { timeoutMs: 5000, signal, env } ); if (result.exitCode !== 0) { return "unknown"; @@ -544,7 +664,7 @@ async function inspectComposeProject( '{{index .Config.Labels "com.docker.compose.service"}}|{{.State.Status}}|{{.State.ExitCode}}', ...ids, ], - { timeoutMs: 5000, signal } + { timeoutMs: 5000, signal, env } ); if (inspected.exitCode !== 0) { return "unknown"; @@ -716,7 +836,9 @@ export function completedDependencyServices( return completed; } -async function readMachineBootId(): Promise { +async function readMachineBootId( + env: Readonly> +): Promise { if (process.platform === "linux") { try { return ( @@ -732,6 +854,7 @@ async function readMachineBootId(): Promise { ["sysctl", "-n", "kern.boottime"], { timeoutMs: 2000, + env, } ); const match = result.stdout.match(MAC_BOOT_TIME_PATTERN); @@ -740,6 +863,44 @@ async function readMachineBootId(): Promise { return null; } +function requireAutostartRuntime( + runtime: AutostartRuntimeOptions | undefined +): AutostartRuntimeOptions { + if (!(runtime && isAbsolute(runtime.home) && isAbsolute(runtime.hackHome))) { + throw new Error("Autostart requires explicit absolute service home paths."); + } + return runtime; +} + +export function resolveAutostartEngineWaitMs(value?: number): number { + const duration = value ?? ENGINE_WAIT_MS; + if (!Number.isSafeInteger(duration) || duration < 1 || duration > 3_600_000) { + throw new Error( + "Autostart engine wait must be from 1 to 3600000 milliseconds." + ); + } + return duration; +} + +/** No Docker, Compose, credential or application settings are inherited from a shell. */ +export function autostartServiceEnvironment( + runtime: AutostartRuntimeOptions +): Record { + requireAutostartRuntime(runtime); + const user = userInfo(); + return { + HOME: runtime.home, + HACK_HOME: runtime.hackHome, + USER: user.username, + LOGNAME: user.username, + TMPDIR: "/tmp", + PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + ...(process.platform === "linux" + ? { XDG_RUNTIME_DIR: `/run/user/${user.uid}` } + : {}), + }; +} + /** Own process group, no TTY, no keychain, no recursive daemon startup, bounded output and lifetime. */ export async function runAutostartSubprocess( argv: readonly string[], @@ -747,6 +908,8 @@ export async function runAutostartSubprocess( readonly timeoutMs: number; readonly signal?: AbortSignal; readonly env?: Readonly>; + readonly projectId?: string; + readonly terminationGraceMs?: number; } ): Promise<{ exitCode: number; @@ -758,12 +921,12 @@ export async function runAutostartSubprocess( } const proc = Bun.spawn([...argv], { env: { - ...childEnvironment(opts.env ?? process.env, { - HACK_NO_INTERACTIVE: "1", - HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", - HACK_DAEMON_AUTOSTART_DISABLE: "1", - }), + ...filterAutostartServiceEnvironment(opts.env ?? {}), + HACK_NO_INTERACTIVE: "1", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + HACK_DAEMON_AUTOSTART_DISABLE: "1", HACK_AUTOSTART_LOGIN: "1", + ...(opts.projectId ? { HACK_AUTOSTART_PROJECT_ID: opts.projectId } : {}), }, stdin: "ignore", stdout: "pipe", @@ -771,15 +934,15 @@ export async function runAutostartSubprocess( detached: true, }); let reason: "output_limit" | "daemon_stopped" | "start_timeout" | undefined; + let stopping: Promise | undefined; const stop = ( failure: "output_limit" | "daemon_stopped" | "start_timeout" ) => { reason ??= failure; - try { - process.kill(-proc.pid, "SIGKILL"); - } catch { - /* Own child may already have exited. */ - } + stopping ??= terminateAutostartGroup({ + pid: proc.pid, + graceMs: opts.terminationGraceMs ?? 2000, + }); }; const timer = setTimeout(() => stop("start_timeout"), opts.timeoutMs); const abort = () => stop("daemon_stopped"); @@ -802,6 +965,7 @@ export async function runAutostartSubprocess( stdout += decoder.decode(chunk, { stream: true }); } const exitCode = await proc.exited; + await stopping; return { exitCode: reason ? 124 : exitCode, stdout: reason ? "" : stdout, @@ -813,3 +977,51 @@ export async function runAutostartSubprocess( opts.signal?.removeEventListener("abort", abort); } } + +const AUTOSTART_SERVICE_ENV_KEYS = new Set([ + "HOME", + "HACK_HOME", + "USER", + "LOGNAME", + "TMPDIR", + "XDG_RUNTIME_DIR", + "PATH", +]); + +function filterAutostartServiceEnvironment( + env: Readonly> +): Record { + const result: Record = {}; + for (const key of AUTOSTART_SERVICE_ENV_KEYS) { + const value = env[key]; + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +async function terminateAutostartGroup(opts: { + readonly pid: number; + readonly graceMs: number; +}): Promise { + const signal = (value: "SIGTERM" | "SIGKILL") => { + try { + process.kill(-opts.pid, value); + } catch { + /* The owned group is already absent. */ + } + }; + signal("SIGTERM"); + const deadline = + performance.now() + Math.max(0, Math.min(opts.graceMs, 5000)); + while (performance.now() < deadline) { + try { + process.kill(-opts.pid, 0); + } catch { + return; + } + await Bun.sleep(Math.min(50, Math.max(1, deadline - performance.now()))); + } + signal("SIGKILL"); +} diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index d088278b3..df83a018a 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -174,6 +174,10 @@ export function renderLaunchdPlist(opts: LaunchdPlistOptions): string { "", " EnvironmentVariables", " ", + " HACK_DAEMON_LOGIN_SERVICE", + " 1", + " HACK_NO_INTERACTIVE", + " 1", " HOME", ` ${opts.home}`, " PATH", diff --git a/src/daemon/login-autostart.ts b/src/daemon/login-autostart.ts new file mode 100644 index 000000000..d8b791a75 --- /dev/null +++ b/src/daemon/login-autostart.ts @@ -0,0 +1,67 @@ +/** Only a login-service renderer grants automatic project replay authority. */ +export function isLoginServiceAutostart(opts: { + readonly env: Readonly>; +}): boolean { + return ( + opts.env.HACK_DAEMON_LOGIN_SERVICE === "1" && + opts.env.HACK_DAEMON_AUTOSTART_DISABLE !== "1" + ); +} + +/** + * Owns one startup pass at a time. Engine availability can resume an unclaimed + * timed-out pass; ordinary daemon starts never acquire this authority. + */ +export function createLoginAutostart(opts: { + readonly enabled: boolean; + readonly run: ( + signal: AbortSignal + ) => Promise<{ readonly waitingForEngine: boolean }>; + readonly onError: () => void; +}): { + readonly start: () => void; + readonly observeEngine: (ready: boolean) => void; + readonly stop: () => Promise; +} { + const abort = new AbortController(); + let started = false; + let waiting = false; + let work: Promise | null = null; + const dispatch = () => { + if (!opts.enabled || abort.signal.aborted || work) { + return; + } + work = opts + .run(abort.signal) + .then( + (result) => { + waiting = result.waitingForEngine; + }, + () => { + waiting = false; + opts.onError(); + } + ) + .finally(() => { + work = null; + }); + }; + return { + start: () => { + if (started) { + return; + } + started = true; + dispatch(); + }, + observeEngine: (ready) => { + if (started && ready && waiting) { + dispatch(); + } + }, + stop: async () => { + abort.abort(); + await work; + }, + }; +} diff --git a/src/daemon/server.ts b/src/daemon/server.ts index ab6895c28..b6a8050bf 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -1,10 +1,16 @@ // biome-ignore-all lint/complexity/noExcessiveCognitiveComplexity: The unsupported experimental gateway server keeps auth and route fan-out inline for traceability. import { Buffer } from "node:buffer"; -import { relative, resolve } from "node:path"; +import { userInfo } from "node:os"; +import { dirname, relative, resolve } from "node:path"; import type { ServerWebSocket } from "bun"; import pkg from "../../package.json"; -import { DAEMON_PROCESS_TITLE } from "../constants.ts"; +import { + DAEMON_PROCESS_TITLE, + GLOBAL_CONFIG_FILENAME, + GLOBAL_DAEMON_DIR_NAME, + GLOBAL_HACK_DIR_NAME, +} from "../constants.ts"; import { loadExtensionManagerForDaemon } from "../control-plane/extensions/daemon.ts"; import { appendGatewayAuditEntry } from "../control-plane/extensions/gateway/audit.ts"; import { authenticateGatewayRequest } from "../control-plane/extensions/gateway/auth.ts"; @@ -17,7 +23,10 @@ import type { ShellMeta, } from "../control-plane/extensions/supervisor/shell-service.ts"; import { createShellService } from "../control-plane/extensions/supervisor/shell-service.ts"; -import type { ControlPlaneConfig } from "../control-plane/sdk/config.ts"; +import { + type ControlPlaneConfig, + readControlPlaneConfig, +} from "../control-plane/sdk/config.ts"; import { ensureDir } from "../lib/fs.ts"; import { resolveRegisteredProjectById } from "../lib/projects-registry.ts"; import { runAutostartAtLogin } from "./autostart.ts"; @@ -31,6 +40,10 @@ import { startDockerEventWatcher, } from "./docker-events.ts"; import { createDaemonLogger } from "./logger.ts"; +import { + createLoginAutostart, + isLoginServiceAutostart, +} from "./login-autostart.ts"; import type { DaemonPaths } from "./paths.ts"; import { removeFileIfExists, writeDaemonPid } from "./process.ts"; import { createRefreshScheduler } from "./refresh-scheduler.ts"; @@ -114,8 +127,48 @@ export async function runDaemon({ lastProjectsResponseBytes: null, }; + const loginAutostart = createLoginAutostart({ + enabled: isLoginServiceAutostart({ env: process.env }), + run: async (signal) => { + const home = userInfo().homedir; + if ( + paths.root !== + resolve(home, GLOBAL_HACK_DIR_NAME, GLOBAL_DAEMON_DIR_NAME) + ) { + throw new Error( + "Login service daemon state root is not the default machine root." + ); + } + const config = await readControlPlaneConfig({ + globalConfigPath: resolve(dirname(paths.root), GLOBAL_CONFIG_FILENAME), + strict: true, + }); + if (config.parseError) { + throw new Error( + "Autostart service configuration is invalid; no project start ran." + ); + } + return await runAutostartAtLogin({ + signal, + runtime: { + home, + hackHome: dirname(paths.root), + serviceOrigin: true, + platform: process.platform, + engineWaitMs: config.config.daemon.autostartEngineWaitMs, + }, + }); + }, + onError: () => + logger.warn({ + message: + "Autostart could not complete; inspect hack autostart list or run hack autostart run --no-interactive. Values omitted.", + }), + }); + const cache = createRuntimeCache({ onRefresh: (snapshot) => { + loginAutostart.observeEngine(snapshot.health.ok); metrics.lastRefreshAtMs = snapshot.health.checkedAtMs ?? snapshot.updatedAtMs; metrics.refreshCount += 1; @@ -186,7 +239,7 @@ export async function runDaemon({ } const refreshIntervalMs = 30_000; - setInterval(() => { + const refreshInterval = setInterval(() => { refreshScheduler.request({ reason: "interval", urgency: "immediate" }); }, refreshIntervalMs); @@ -338,12 +391,10 @@ export async function runDaemon({ } } - const autostartAbort = new AbortController(); - let autostartWork: Promise = Promise.resolve(); const shutdown = async ({ reason }: { readonly reason: string }) => { logger.warn({ message: `Shutting down hackd (${reason})` }); - autostartAbort.abort(); - await autostartWork; + clearInterval(refreshInterval); + await loginAutostart.stop(); refreshScheduler.stop(); watcher.stop(); await daemonProxy?.close(); @@ -364,16 +415,7 @@ export async function runDaemon({ logger.info({ message: `hackd started (pid ${process.pid}, version ${packageJson.version})`, }); - if (process.env.HACK_DAEMON_AUTOSTART_DISABLE !== "1") { - autostartWork = runAutostartAtLogin({ - signal: autostartAbort.signal, - }).catch(() => { - logger.warn({ - message: - "Autostart could not complete; inspect hack autostart list and run hack up manually. Values omitted.", - }); - }); - } + loginAutostart.start(); } async function handleRequest({ diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts index 14e99bc83..fb6ce9bcd 100644 --- a/src/daemon/spawn.ts +++ b/src/daemon/spawn.ts @@ -22,6 +22,7 @@ export function spawnDetachedDaemon(opts: { // for that one repair, not for the daemon and everything it starts. const env: Record = { ...process.env }; Reflect.deleteProperty(env, "HACK_DAEMON_SERVICE_READ_ONLY"); + Reflect.deleteProperty(env, "HACK_DAEMON_LOGIN_SERVICE"); const proc = Bun.spawn([...opts.argv], { env: hackReexecEnvironment(env), stdin: "ignore", diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 20d60b92e..57314d315 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -50,9 +50,10 @@ import { * `hack daemon install`. * - `Restart=on-failure`: a crash restarts hackd; `hack daemon stop` (a clean * stop through systemctl) does not. Start limits stop a crash loop. - * - The default `KillMode=control-group` is kept on purpose: stopping or - * restarting the service also stops processes hackd started (supervisor - * jobs, shells), so none outlive the service that owns them. + * - Keep `KillMode=control-group`: stopping or restarting the service + * settles its transient supervisor jobs and shells. Linux login autostart + * starts each project in a distinct user scope, so project-owned detached + * lifecycle helpers survive replacement of the daemon service. * - Every `systemctl`/`loginctl` call goes through a {@link ServiceCommandRunner} * so tests use a fake. The default runner re-checks * {@link resolveSystemdAccess} before it spawns anything. @@ -502,12 +503,14 @@ export function renderSystemdUnit(opts: { "", "[Service]", "Type=simple", + "KillMode=control-group", `ExecStart="${escapeSpecifiers(bin)}" daemon start --foreground`, "Restart=on-failure", "RestartSec=2", "WorkingDirectory=%h", `Environment="PATH=${path}"`, "Environment=HACK_NO_INTERACTIVE=1", + "Environment=HACK_DAEMON_LOGIN_SERVICE=1", // Applied last by systemd, so it also strips what the user manager's // environment carries at each login start (HACK-2052). `UnsetEnvironment=${CODE_LOADING_ENV_KEYS.join(" ")}`, diff --git a/src/lib/autostart-project-lock.ts b/src/lib/autostart-project-lock.ts new file mode 100644 index 000000000..ac364ae74 --- /dev/null +++ b/src/lib/autostart-project-lock.ts @@ -0,0 +1,95 @@ +import { createHash } from "node:crypto"; +import { lstat, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { HackCliError } from "./cli-result.ts"; +import { + readProjectsRegistry, + resolveProjectsRegistryPath, +} from "./projects-registry.ts"; +import { withProjectsRegistryLock } from "./projects-registry-lock.ts"; + +export interface AutostartProjectScope { + readonly projectId: string; + readonly composeProject: string; + readonly branch: string | null; +} + +/** + * Start and stop share one machine-local project/instance lock. A stop publishes + * its pause before waiting, then holds the lock through runtime shutdown. Thus + * an earlier login child cannot outlive a successful stop and start afterwards. + * The lock owner is the actual CLI child, not its daemon parent. + */ +export async function withAutostartProjectLock(opts: { + readonly scope: AutostartProjectScope; + readonly registryPath?: string; + readonly timeoutMs?: number; + readonly run: () => Promise; +}): Promise { + const registryPath = opts.registryPath ?? resolveProjectsRegistryPath(); + const directory = join(dirname(registryPath), "autostart-locks"); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const info = await lstat(directory); + if ( + !info.isDirectory() || + info.isSymbolicLink() || + (info.mode & 0o022) !== 0 || + (process.getuid && info.uid !== process.getuid()) + ) { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Project start/stop lock directory is unsafe; no runtime operation ran.", + }); + } + const key = createHash("sha256") + .update( + JSON.stringify({ + composeProject: opts.scope.composeProject, + branch: opts.scope.branch, + }) + ) + .digest("hex"); + return await withProjectsRegistryLock({ + lockPath: join(directory, `${key}.lock`), + timeoutMs: opts.timeoutMs ?? 180_000, + label: "Project start/stop", + run: opts.run, + }); +} + +/** Must run while the project lock is held, again immediately before containers start. */ +export async function assertAutostartProjectCurrent(opts: { + readonly scope: Pick; + readonly expectedRoot?: string; + readonly registryPath?: string; +}): Promise { + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + requireAutostartTrust: true, + }); + const projects = registry.projects.filter( + (project) => project.id === opts.scope.projectId + ); + const marker = projects.length === 1 ? projects[0]?.autostart : undefined; + let reason: string | null = null; + if (registry.autostartGlobal?.paused) { + reason = "global_paused"; + } else if (!marker || marker.paused || marker.invalid) { + reason = "paused"; + } else if ( + marker.branch !== opts.scope.branch || + (opts.expectedRoot !== undefined && + projects[0]?.repoRoot !== opts.expectedRoot) + ) { + reason = "target_changed"; + } + if (reason) { + throw new HackCliError({ + code: "E_CANCELLED", + message: + "Autostart was cancelled by changed machine intent; no further startup effects ran.", + detail: { reason }, + }); + } +} diff --git a/src/lib/autostart-view.ts b/src/lib/autostart-view.ts index c1bdf13c4..0da771ae3 100644 --- a/src/lib/autostart-view.ts +++ b/src/lib/autostart-view.ts @@ -12,6 +12,7 @@ import type { export interface AutostartLoginRunView { readonly in_progress: boolean; + readonly waiting_for_engine: boolean; readonly started_at: string | null; /** Registry project IDs, including active starts until their outcome is recorded. */ readonly pending: readonly string[]; @@ -126,7 +127,8 @@ export function projectAutostartFromGlobal(opts: { return serializeProjectAutostart(opts.project?.autostart, { globallyPaused: opts.global.paused, starting: - opts.global.login_run.in_progress && + (opts.global.login_run.in_progress || + opts.global.login_run.waiting_for_engine) && opts.project !== undefined && opts.global.login_run.pending.includes(opts.project.id), }); @@ -141,6 +143,7 @@ export function serializeAutostartGlobal( paused: state.paused, login_run: { in_progress: state.loginRun.inProgress, + waiting_for_engine: state.loginRun.waitingForEngine, started_at: state.loginRun.startedAt, pending: [...state.loginRun.pending], started: [...state.loginRun.started], diff --git a/src/lib/child-environment.ts b/src/lib/child-environment.ts index bc454b156..086f5b198 100644 --- a/src/lib/child-environment.ts +++ b/src/lib/child-environment.ts @@ -22,7 +22,9 @@ export function childEnvironment( if ( typeof value === "string" && !unsetKeys.includes(key) && - key !== "HACK_AUTOSTART_LOGIN" + key !== "HACK_AUTOSTART_LOGIN" && + key !== "HACK_AUTOSTART_PROJECT_ID" && + key !== "HACK_DAEMON_LOGIN_SERVICE" ) { env[key] = value; } @@ -47,6 +49,9 @@ export function hackReexecEnvironment( const env = childEnvironment(base); if (base.HACK_AUTOSTART_LOGIN === "1") { env.HACK_AUTOSTART_LOGIN = "1"; + if (base.HACK_AUTOSTART_PROJECT_ID) { + env.HACK_AUTOSTART_PROJECT_ID = base.HACK_AUTOSTART_PROJECT_ID; + } } for (const key of HACK_KEY_MATERIAL_VARIABLES) { const value = base[key]; diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 042628296..dfd3f89b9 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -75,6 +75,7 @@ export interface AutostartLoginRun { readonly runId: string | null; readonly bootId: string | null; readonly inProgress: boolean; + readonly waitingForEngine: boolean; readonly startedAt: string | null; readonly pending: readonly string[]; readonly started: readonly string[]; @@ -227,7 +228,8 @@ export function isTrustedAutostartRegistryFile(opts: { /** No-follow, non-blocking and bounded read from the same descriptor we validate. */ async function readTrustedAutostartRegistryText( - path: string + path: string, + allowLegacyWritable = false ): Promise { let file: FileHandle; try { @@ -249,7 +251,9 @@ async function readTrustedAutostartRegistryText( regular: info.isFile(), fileUid: info.uid, currentUid: process.getuid?.(), - mode: info.mode, + // Only explicit first opt-in may rebuild unmarked legacy state. The + // descriptor's regular-file, owner and byte bounds still apply. + mode: allowLegacyWritable ? info.mode & ~0o022 : info.mode, size: info.size, }) ) { @@ -793,6 +797,7 @@ export function getDefaultAutostartGlobal(): AutostartGlobal { runId: null, bootId: null, inProgress: false, + waitingForEngine: false, startedAt: null, pending: [], started: [], @@ -834,6 +839,7 @@ export async function beginAutostartLoginRun( readonly bootId: string; readonly startedAt: string; readonly pendingIds: readonly string[]; + readonly waitingForEngine?: boolean; } ): Promise { if (!(opts.bootId.trim() && Number.isFinite(Date.parse(opts.startedAt)))) { @@ -860,6 +866,7 @@ export async function beginAutostartLoginRun( runId: randomUUID(), bootId: opts.bootId, inProgress: true, + waitingForEngine: opts.waitingForEngine ?? false, startedAt: opts.startedAt, pending, started: [], @@ -912,9 +919,41 @@ export async function recordAutostartLoginOutcome( }); } -/** Close a run; unfinished or cancelled work has no observed failure outcome. */ +/** Publish engine readiness for the active run without touching project claims or outcomes. */ +export async function setAutostartLoginWaitingForEngine( + opts: AutostartRegistryOptions & { + readonly runId: string; + readonly waitingForEngine: boolean; + } +): Promise { + return await mutateAutostartRegistry({ + ...opts, + update: (current) => { + const global = current.autostartGlobal ?? getDefaultAutostartGlobal(); + const run = global.loginRun; + if (!run.inProgress || run.runId !== opts.runId) { + return { next: current, value: false }; + } + return { + next: { + ...current, + autostartGlobal: { + ...global, + loginRun: { ...run, waitingForEngine: opts.waitingForEngine }, + }, + }, + value: true, + }; + }, + }); +} + +/** Close a run; engine deferral retains unclaimed work for a later readiness pass. */ export async function finishAutostartLoginRun( - opts: AutostartRegistryOptions & { readonly runId: string } + opts: AutostartRegistryOptions & { + readonly runId: string; + readonly waitingForEngine?: boolean; + } ): Promise { return await mutateAutostartRegistry({ ...opts, @@ -927,7 +966,8 @@ export async function finishAutostartLoginRun( const loginRun: AutostartLoginRun = { ...run, inProgress: false, - pending: [], + waitingForEngine: opts.waitingForEngine ?? false, + pending: opts.waitingForEngine ? run.pending : [], failed: run.failed, }; return { @@ -987,11 +1027,14 @@ export async function setProjectAutostart( opts: AutostartRegistryOptions & { readonly id: string; readonly branch?: string | null; + /** Fired only after rebuilding an owned, unmarked legacy registry on a new private inode. */ + readonly onPermissionsRepaired?: () => void; } ): Promise { let selected: RegisteredProject | undefined; await mutateProjectAutostart({ ...opts, + allowLegacyPermissionRepair: true, update: (project) => { const branch = opts.branch ?? null; const old = project.autostart; @@ -1127,6 +1170,8 @@ async function mutateProjectAutostart( opts: AutostartRegistryOptions & { readonly id?: string; readonly respectGlobalPause?: boolean; + readonly allowLegacyPermissionRepair?: boolean; + readonly onPermissionsRepaired?: () => void; readonly update: (project: RegisteredProject) => RegisteredProject; } ): Promise { @@ -1136,7 +1181,11 @@ async function mutateProjectAutostart( lockPath: `${registryPath}.lock`, signal: opts.signal, run: async () => { - const current = await readTrustedAutostartRegistry({ registryPath }); + const { registry: current, permissionsRepaired } = + await readAutostartMutationRegistry({ + registryPath, + allowLegacyPermissionRepair: opts.allowLegacyPermissionRepair, + }); if (opts.respectGlobalPause && current.autostartGlobal?.paused) { return []; } @@ -1172,12 +1221,84 @@ async function mutateProjectAutostart( { ...current, projects }, opts.signal ); + if (permissionsRepaired) { + opts.onPermissionsRepaired?.(); + } } return selected; }, }); } +/** + * An explicit first opt-in may copy current-user-owned unmarked v4 registrations + * to a fresh 0600 inode. Existing automatic-start intent cannot be authenticated + * this way: it requires separate preservation and a reviewed rebuild instead. + */ +async function readAutostartMutationRegistry(opts: { + readonly registryPath: string; + readonly allowLegacyPermissionRepair?: boolean; +}): Promise<{ + readonly registry: ProjectsRegistry; + readonly permissionsRepaired: boolean; +}> { + try { + return { + registry: await readTrustedAutostartRegistry(opts), + permissionsRepaired: false, + }; + } catch (error: unknown) { + if ( + !( + opts.allowLegacyPermissionRepair && + error instanceof AutostartRegistryTrustError + ) + ) { + throw error; + } + } + const text = await readTrustedAutostartRegistryText(opts.registryPath, true); + let value: unknown; + try { + value = + text === null + ? { version: REGISTRY_VERSION, projects: [] } + : JSON.parse(text); + } catch { + throw new AutostartRegistryTrustError(); + } + const registry = parseRegistry(value); + if ( + !(registry && isRecord(value) && Array.isArray(value.projects)) || + registry.projects.length !== value.projects.length + ) { + throw new AutostartRegistryTrustError(); + } + if ( + value.autostartGlobal !== undefined || + value.projects.some( + (project: unknown) => isRecord(project) && project.autostart !== undefined + ) + ) { + throw new AutostartRegistryTrustError(); + } + if ( + value.projects.some((project: unknown, index) => { + if (!isRecord(project) || project.worktrees === undefined) { + return false; + } + return ( + !Array.isArray(project.worktrees) || + project.worktrees.length !== + (registry.projects[index]?.worktrees?.length ?? 0) + ); + }) + ) { + throw new AutostartRegistryTrustError(); + } + return { registry, permissionsRepaired: true }; +} + /** Validate every requested ID under ownership before removing any entry. */ export async function removeProjectsById(opts: { readonly ids: readonly string[]; @@ -1282,6 +1403,8 @@ function parseAutostartGlobal(value: unknown): AutostartGlobal | undefined { (run.runId === null || typeof run.runId === "string") && (run.bootId === null || typeof run.bootId === "string") && typeof run.inProgress === "boolean" && + (run.waitingForEngine === undefined || + typeof run.waitingForEngine === "boolean") && (run.startedAt === null || (typeof run.startedAt === "string" && Number.isFinite(Date.parse(run.startedAt)))) && @@ -1300,6 +1423,7 @@ function parseAutostartGlobal(value: unknown): AutostartGlobal | undefined { runId: run.runId, bootId: run.bootId, inProgress: run.inProgress, + waitingForEngine: run.waitingForEngine ?? false, startedAt: run.startedAt, pending: run.pending, started: run.started, @@ -1472,6 +1596,11 @@ function getRegistryPath(): string { ); } +/** Resolve the machine registry shared by intent, login admission and foreground command locks. */ +export function resolveProjectsRegistryPath(): string { + return getRegistryPath(); +} + function getRegistryLockPath(): string { return resolve(resolveGlobalRegistryRoot(), REGISTRY_LOCK_FILENAME); } diff --git a/src/templates.ts b/src/templates.ts index 6963a1225..931c29877 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -952,6 +952,12 @@ export function renderProjectConfigSchemaJson(): string { additionalProperties: true, properties: { autoStart: { type: "boolean" }, + autostartEngineWaitMs: { + type: "integer", + minimum: 1, + maximum: 3_600_000, + default: 600_000, + }, }, }, gateway: { diff --git a/tests/autostart-cli.test.ts b/tests/autostart-cli.test.ts index cdd976f2d..5886394bb 100644 --- a/tests/autostart-cli.test.ts +++ b/tests/autostart-cli.test.ts @@ -29,6 +29,21 @@ const project: RegisteredProject = { const mutations: unknown[] = []; let reads = 0; let globalState = getDefaultAutostartGlobal(); +let explicitRuns = 0; +let explicitEngineWaitMs: number | undefined; +const startupMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/daemon/autostart.ts", + overrides: { + runAutostartAtLogin: async (options: { + readonly engineWaitMs?: number; + }) => { + explicitEngineWaitMs = options.engineWaitMs; + explicitRuns += 1; + return { waitingForEngine: false }; + }, + }, +}); const viewMock = await registerScopedModuleMock({ importerPath: import.meta.path, specifier: "../src/lib/autostart-view.ts", @@ -76,6 +91,7 @@ const registryMock = await registerScopedModuleMock({ }, }); beforeAll(() => { + startupMock.activate(); viewMock.activate(); registryMock.activate(); gitMock.activate(); @@ -84,10 +100,13 @@ afterAll(() => { gitMock.deactivate(); registryMock.deactivate(); viewMock.deactivate(); + startupMock.deactivate(); }); beforeEach(() => { mutations.length = 0; reads = 0; + explicitRuns = 0; + explicitEngineWaitMs = undefined; linkedRoots.clear(); globalState = getDefaultAutostartGlobal(); }); @@ -122,7 +141,13 @@ test("autostart add selects current registered project and passes branch without }) ); expect(result.code).toBe(0); - expect(mutations).toEqual([{ id: "example-id", branch: "preview" }]); + expect(mutations).toEqual([ + { + id: "example-id", + branch: "preview", + onPermissionsRepaired: expect.any(Function), + }, + ]); expect(result.output).toContain("hack daemon install"); }); @@ -369,3 +394,58 @@ test("explicit --project and positional name refuse before any registry read", a expect(reads).toBe(0); expect(mutations).toEqual([]); }); + +test("explicit autostart run intentionally invokes the scheduler and emits JSON", async () => { + try { + const result = await capture(() => + runCli(["autostart", "run", "--json", "--no-interactive"]) + ); + expect(result.code).toBe(0); + expect(explicitRuns).toBe(1); + expect(JSON.parse(result.output)).toMatchObject({ + ok: true, + data: { + action: "run", + waiting_for_engine: false, + autostart_global: { paused: false }, + }, + }); + } finally { + resetNoInteractiveFlagForTests(); + } +}); + +test("explicit autostart run validates and forwards a user-supplied engine wait bound", async () => { + try { + const valid = await capture(() => + runCli([ + "autostart", + "run", + "--engine-wait-ms", + "5000", + "--json", + "--no-interactive", + ]) + ); + expect(valid.code).toBe(0); + expect(explicitEngineWaitMs).toBe(5000); + expect(explicitRuns).toBe(1); + const invalid = await capture(() => + runCli([ + "autostart", + "run", + "--engine-wait-ms=0", + "--json", + "--no-interactive", + ]) + ); + expect(invalid.code).toBe(1); + expect(JSON.parse(invalid.output)).toMatchObject({ + ok: false, + error: { code: "E_USAGE" }, + }); + expect(explicitRuns).toBe(1); + } finally { + resetNoInteractiveFlagForTests(); + } +}); diff --git a/tests/autostart-lifecycle-intent.test.ts b/tests/autostart-lifecycle-intent.test.ts index ccd63073a..2ba121bbb 100644 --- a/tests/autostart-lifecycle-intent.test.ts +++ b/tests/autostart-lifecycle-intent.test.ts @@ -3,6 +3,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { classifyAutostartExit } from "../src/daemon/autostart.ts"; +import { + assertAutostartProjectCurrent, + withAutostartProjectLock, +} from "../src/lib/autostart-project-lock.ts"; import { privateCliEnv } from "./helpers/private-cli-env.ts"; let root = ""; @@ -72,6 +76,7 @@ async function run( env: { PATH: join(root, "bin"), HACK_AUTOSTART_LOGIN: login ? "1" : "0", + HACK_AUTOSTART_PROJECT_ID: login ? "sample" : undefined, ...overrides, }, }), @@ -115,14 +120,15 @@ test("down accepts legacy alias names when deciding whether to pause", async () await run(["down", "--project", "SAMPLE_APP", "--json"]); expect((await marker()).paused).toBe(true); }); -test("login refuses a pending legacy migration without touching dotenv or clearing pause", async () => { +test("login refuses a pending legacy migration without touching dotenv or changing intent", async () => { + await writeRegistry(false); const envFile = join(root, "project", ".hack", ".env"); await writeFile(envFile, "SAMPLE_VALUE=fixture\n"); const result = await run(["up", "--json", "-d"], true); expect(result.exitCode).not.toBe(0); expect(result.stdout + result.stderr).toContain("pending env migration"); expect(await Bun.file(envFile).text()).toBe("SAMPLE_VALUE=fixture\n"); - expect((await marker()).paused).toBe(true); + expect((await marker()).paused).toBe(false); expect(classifyAutostartExit(result)).toEqual({ status: "skipped", reason: "pending_env_migration", @@ -209,3 +215,120 @@ test("native selection with an existing marker reaches the same dispatch as an u expect((await marker()).paused).toBe(action === "down"); } }); + +async function waitForCondition( + check: () => Promise, + message: string +) { + const deadline = Date.now() + 8000; + while (Date.now() < deadline) { + if (await check()) { + return; + } + await Bun.sleep(10); + } + throw new Error(message); +} + +test("source down pauses before waiting for a start and holds admission through runtime shutdown", async () => { + await writeRegistry(false); + const registryPath = join(root, "home", ".hack", "projects.json"); + const enteredDown = join(root, "down-entered"); + const releaseDown = join(root, "release-down"); + await writeFile( + join(root, "bin", "docker"), + `#!${process.execPath}\nconst args = process.argv.slice(2);\nif (args.includes("down")) { await Bun.write(${JSON.stringify(enteredDown)}, "entered"); const deadline=Date.now()+10000; while (!(await Bun.file(${JSON.stringify(releaseDown)}).exists())) { if(Date.now()>deadline) process.exit(99); await Bun.sleep(10); } } else if (args.includes("ps") || args.includes("inspect")) { process.stdout.write("[]"); }\n`, + { mode: 0o755 } + ); + const scope = { projectId: "sample", composeProject: "sample", branch: null }; + const startEntered = Promise.withResolvers(); + const releaseStart = Promise.withResolvers(); + let firstStartPublished = false; + const firstStart = withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + startEntered.resolve(); + await releaseStart.promise; + await assertAutostartProjectCurrent({ scope, registryPath }); + firstStartPublished = true; + }, + }).then( + () => ({ refused: false }), + (error: unknown) => ({ refused: true, error }) + ); + await startEntered.promise; + let downFinished = false; + const down = run(["down", "--json"]).then((result) => { + downFinished = true; + return result; + }); + let queuedStartEntered = false; + let secondStart: Promise | undefined; + try { + await waitForCondition( + async () => (await marker()).paused === true, + "source down did not publish its pause before waiting" + ); + expect(downFinished).toBe(false); + expect(await Bun.file(enteredDown).exists()).toBe(false); + releaseStart.resolve(); + const firstOutcome = await firstStart; + expect(firstOutcome).toMatchObject({ + refused: true, + error: { code: "E_CANCELLED", detail: { reason: "paused" } }, + }); + expect(firstStartPublished).toBe(false); + await waitForCondition( + () => Bun.file(enteredDown).exists(), + "source down never reached the fake runtime shutdown" + ); + secondStart = withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + queuedStartEntered = true; + await assertAutostartProjectCurrent({ scope, registryPath }); + }, + }).then( + () => ({ refused: false }), + (error: unknown) => ({ refused: true, error }) + ); + await Bun.sleep(75); + expect(queuedStartEntered).toBe(false); + expect(downFinished).toBe(false); + await Bun.write(releaseDown, "release"); + const result = await down; + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(await secondStart).toMatchObject({ + refused: true, + error: { code: "E_CANCELLED", detail: { reason: "paused" } }, + }); + expect((await marker()).paused).toBe(true); + } finally { + releaseStart.resolve(); + await Bun.write(releaseDown, "release"); + await Promise.allSettled([ + firstStart, + down, + ...(secondStart ? [secondStart] : []), + ]); + } +}, 20_000); + +test("a login child cannot resume a manually paused project or substitute a different registry identity", async () => { + const paused = await run(["up", "--json", "-d"], true); + expect(paused.exitCode).not.toBe(0); + expect(paused.stdout).toContain("E_CANCELLED"); + expect((await marker()).paused).toBe(true); + await writeRegistry(false); + const mismatched = await run( + ["up", "--json", "-d"], + true, + join(root, "project"), + { HACK_AUTOSTART_PROJECT_ID: "different-project" } + ); + expect(mismatched.exitCode).not.toBe(0); + expect(mismatched.stdout).toContain("project path changed"); + expect((await marker()).paused).toBe(false); +}); diff --git a/tests/autostart-project-lock.test.ts b/tests/autostart-project-lock.test.ts new file mode 100644 index 000000000..57d7ec7b5 --- /dev/null +++ b/tests/autostart-project-lock.test.ts @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + assertAutostartProjectCurrent, + withAutostartProjectLock, +} from "../src/lib/autostart-project-lock.ts"; + +let root = ""; +let registryPath = ""; +const scope = { + projectId: "example-id", + composeProject: "example", + branch: null, +}; +const marker = { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: null, +}; +function project(autostart: unknown = marker) { + return { + id: scope.projectId, + name: "example", + repoRoot: "/example/project", + projectDir: "/example/project/.hack", + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + ...(autostart ? { autostart } : {}), + }; +} +async function registry(projects: unknown[], global?: unknown) { + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects, + ...(global ? { autostartGlobal: global } : {}), + }), + { mode: 0o600 } + ); +} +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-autostart-lock-")); + registryPath = join(root, "projects.json"); + await registry([project()]); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +test("start/stop admission serializes the Compose instance even after registry identity changes", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const first = withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + entered.resolve(); + await release.promise; + }, + }); + await entered.promise; + let secondRan = false; + try { + await expect( + withAutostartProjectLock({ + scope: { ...scope, projectId: "renamed-id" }, + registryPath, + timeoutMs: 30, + run: async () => { + secondRan = true; + }, + }) + ).rejects.toThrow("Project start/stop"); + expect(secondRan).toBe(false); + let independentBranchRan = false; + await withAutostartProjectLock({ + scope: { ...scope, branch: "preview" }, + registryPath, + timeoutMs: 30, + run: async () => { + independentBranchRan = true; + }, + }); + expect(independentBranchRan).toBe(true); + } finally { + release.resolve(); + await first; + } + await withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + secondRan = true; + }, + }); + expect(secondRan).toBe(true); +}); + +test("rechecking intent accepts exactly one unpaused matching registered target", async () => { + await expect( + assertAutostartProjectCurrent({ scope, registryPath }) + ).resolves.toBeUndefined(); + for (const entry of [ + undefined, + { ...marker, paused: true }, + { ...marker, branch: "preview" }, + { paused: "invalid" }, + ]) { + await registry([project(entry === undefined ? null : entry)]); + await expect( + assertAutostartProjectCurrent({ scope, registryPath }) + ).rejects.toMatchObject({ code: "E_CANCELLED" }); + } + await registry([project(), project()]); + await expect( + assertAutostartProjectCurrent({ scope, registryPath }) + ).rejects.toMatchObject({ + code: "E_CANCELLED", + detail: { reason: "paused" }, + }); + await registry([project()], { + paused: true, + loginRun: { + runId: null, + bootId: null, + inProgress: false, + waitingForEngine: false, + startedAt: null, + pending: [], + started: [], + failed: [], + }, + }); + await expect( + assertAutostartProjectCurrent({ scope, registryPath }) + ).rejects.toMatchObject({ + code: "E_CANCELLED", + detail: { reason: "global_paused" }, + }); +}); + +for (const kind of ["writable", "symlink"]) { + test(`an unsafe ${kind} lock directory refuses before running project effects`, async () => { + const locks = join(root, "autostart-locks"); + if (kind === "symlink") { + const target = join(root, "other"); + await mkdir(target); + await symlink(target, locks); + } else { + await mkdir(locks); + await chmod(locks, 0o777); + } + let ran = false; + await expect( + withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + ran = true; + }, + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(ran).toBe(false); + }); +} diff --git a/tests/autostart-view.test.ts b/tests/autostart-view.test.ts index 6a8535dba..641316b71 100644 --- a/tests/autostart-view.test.ts +++ b/tests/autostart-view.test.ts @@ -33,6 +33,7 @@ test("desktop policy states distinguish off, starting, global pause and manual s runId: "private-run-id", bootId: "private-boot-id", inProgress: true, + waitingForEngine: false, startedAt: "2026-01-01T00:00:00Z", pending: [project.id], started: ["ready-id"], @@ -53,6 +54,19 @@ test("desktop policy states distinguish off, starting, global pause and manual s }, max_failures: 3, }); + expect( + projectAutostartFromGlobal({ + project, + global: { + ...global, + login_run: { + ...global.login_run, + in_progress: false, + waiting_for_engine: true, + }, + }, + }) + ).toMatchObject({ state: "starting" }); expect( projectAutostartFromGlobal({ project, global: { ...global, paused: true } }) ).toMatchObject({ state: "paused", paused_reason: null }); @@ -77,6 +91,7 @@ test("desktop policy states distinguish off, starting, global pause and manual s service_ready: true, login_run: { in_progress: true, + waiting_for_engine: false, started_at: "2026-01-01T00:00:00Z", pending: ["example-id"], started: ["ready-id"], diff --git a/tests/child-environment.test.ts b/tests/child-environment.test.ts index 7cdce54e1..f9c3b6600 100644 --- a/tests/child-environment.test.ts +++ b/tests/child-environment.test.ts @@ -257,11 +257,24 @@ test.skipIf(!compiledCli)( } ); -test("login replay context stops at tool boundaries and survives only deliberate Hack reexec", () => { - expect(childEnvironment({ HACK_AUTOSTART_LOGIN: "1", KEEP: "yes" })).toEqual({ +test("login replay authority stops at tool boundaries and survives only deliberate Hack reexec", () => { + const input = { + HACK_AUTOSTART_LOGIN: "1", + HACK_AUTOSTART_PROJECT_ID: "fixture-project", + HACK_DAEMON_LOGIN_SERVICE: "1", KEEP: "yes", - }); - expect(hackReexecEnvironment({ HACK_AUTOSTART_LOGIN: "1" })).toEqual({ + }; + expect(childEnvironment(input)).toEqual({ KEEP: "yes" }); + expect(hackReexecEnvironment(input)).toEqual({ HACK_AUTOSTART_LOGIN: "1", + HACK_AUTOSTART_PROJECT_ID: "fixture-project", + KEEP: "yes", }); + expect( + hackReexecEnvironment({ + HACK_AUTOSTART_PROJECT_ID: "fixture-project", + HACK_DAEMON_LOGIN_SERVICE: "1", + }) + ).toEqual({}); + expect(input.HACK_DAEMON_LOGIN_SERVICE).toBe("1"); }); diff --git a/tests/control-plane-config.test.ts b/tests/control-plane-config.test.ts index c8c4adda1..8d5d68e32 100644 --- a/tests/control-plane-config.test.ts +++ b/tests/control-plane-config.test.ts @@ -398,3 +398,56 @@ test("readControlPlaneConfig merges secrets backend strategy config", async () = expect(projectOverride.config.secrets.cloud.project).toBe("dev-account"); expect(projectOverride.config.secrets.cloud.secretPrefix).toBe("hack"); }); + +test("daemon engine wait defaults to ten minutes and accepts bounded machine configuration", async () => { + expect( + (await readControlPlaneConfig({})).config.daemon.autostartEngineWaitMs + ).toBe(600_000); + if (!tempGlobalConfig) { + throw new Error("Missing fixture config path"); + } + for (const duration of [1, 900_000, 3_600_000]) { + await writeFile( + tempGlobalConfig, + JSON.stringify({ + controlPlane: { daemon: { autostartEngineWaitMs: duration } }, + }) + ); + const result = await readControlPlaneConfig({}); + expect(result.parseError).toBeUndefined(); + expect(result.config.daemon.autostartEngineWaitMs).toBe(duration); + } + for (const duration of [0, 3_600_001, 1.5, "900000"]) { + await writeFile( + tempGlobalConfig, + JSON.stringify({ + controlPlane: { daemon: { autostartEngineWaitMs: duration } }, + }) + ); + expect((await readControlPlaneConfig({})).parseError).toBeDefined(); + } +}); + +test("service configuration uses an explicit machine path instead of a shell override", async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-daemon-config-")); + const ownedPath = join(tempDir, "machine.json"); + if (!tempGlobalConfig) { + throw new Error("Missing fixture config path"); + } + await writeFile( + tempGlobalConfig, + JSON.stringify({ controlPlane: { daemon: { autostartEngineWaitMs: 1 } } }) + ); + await writeFile( + ownedPath, + JSON.stringify({ + controlPlane: { daemon: { autostartEngineWaitMs: 900_000 } }, + }) + ); + const result = await readControlPlaneConfig({ + globalConfigPath: ownedPath, + strict: true, + }); + expect(result.parseError).toBeUndefined(); + expect(result.config.daemon.autostartEngineWaitMs).toBe(900_000); +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index 18f450016..c5ab01721 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -6,9 +6,12 @@ import { type AutostartDependencies, autostartComposeModel, autostartRuntimeRefusal, + autostartServiceEnvironment, + buildAutostartProjectInvocation, classifyAutostartContainers, classifyAutostartExit, completedDependencyServices, + resolveAutostartEngineWaitMs, runAutostartAtLogin, runAutostartSubprocess, } from "../src/daemon/autostart.ts"; @@ -90,8 +93,12 @@ async function seed( await chmod(registryPath, 0o600); } -async function run(signal?: AbortSignal): Promise { - await runAutostartAtLogin({ registryPath, dependencies: deps, signal }); +async function run(signal?: AbortSignal) { + return await runAutostartAtLogin({ + registryPath, + dependencies: deps, + signal, + }); } async function results() { @@ -113,9 +120,9 @@ test("login waits for engine, restores installed routing, then starts in stable await run(); expect(events).toEqual(["routing", "alpha", "bravo", "charlie"]); expect(starts).toEqual([ - { id: "alpha", at: 4000 }, - { id: "bravo", at: 4500 }, - { id: "charlie", at: 5000 }, + { id: "alpha", at: 6000 }, + { id: "bravo", at: 6500 }, + { id: "charlie", at: 7000 }, ]); expect( (await results()).every((entry) => entry.result?.status === "started") @@ -184,15 +191,40 @@ test("pause while inspecting prevents dispatch", async () => { ); }); -test("engine wait has a finite bound and records a closed skip reason", async () => { +test("engine wait uses bounded exponential backoff and leaves attempts unclaimed for a later pass", async () => { await seed(["alpha"]); - deps = { ...deps, engineReady: async () => false }; - await run(); - expect(clock).toBe(60_000); + const delays: number[] = []; + deps = { + ...deps, + engineReady: async () => false, + sleep: async (ms) => { + delays.push(ms); + clock += ms; + }, + }; + expect(await run()).toEqual({ waitingForEngine: true }); + expect(clock).toBe(600_000); + expect(delays.slice(0, 5)).toEqual([2000, 4000, 8000, 16_000, 30_000]); + expect(Math.max(...delays)).toBe(30_000); expect(starts).toEqual([]); - expect((await results())[0]?.result).toMatchObject({ - status: "skipped", - reason: "engine_timeout", + const project = (await readProjectsRegistry({ registryPath })).projects[0]; + expect(project?.autostart?.lastAttemptBootId).toBeNull(); + expect(project?.autostart?.lastResult).toBeNull(); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + waitingForEngine: true, + pending: ["alpha"], + failed: [], + }); + deps = { ...deps, engineReady: async () => true }; + expect(await run()).toEqual({ waitingForEngine: false }); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + waitingForEngine: false, + pending: [], + started: ["alpha"], + failed: [], }); }); @@ -734,3 +766,204 @@ test("ambiguous Compose models cannot prove adoption", () => { autostartComposeModel({ services: { web: { profiles: "debug" } } }) ).toBeNull(); }); + +test("engine wait is explicitly configurable and visible while probing", async () => { + await seed(["alpha"]); + expect(resolveAutostartEngineWaitMs()).toBe(600_000); + expect(() => resolveAutostartEngineWaitMs(0)).toThrow(); + expect(() => resolveAutostartEngineWaitMs(3_600_001)).toThrow(); + deps = { + ...deps, + engineReady: async () => { + expect( + (await readAutostartGlobal({ registryPath })).loginRun.waitingForEngine + ).toBe(true); + return false; + }, + }; + expect( + await runAutostartAtLogin({ + registryPath, + dependencies: deps, + engineWaitMs: 5000, + }) + ).toEqual({ waitingForEngine: true }); + expect(clock).toBe(5000); +}); + +test("service environment ignores shell Docker Compose and Hack overrides", async () => { + const env = autostartServiceEnvironment({ + home: root, + hackHome: join(root, "state"), + }); + expect(Object.keys(env).sort()).toEqual( + (process.platform === "linux" + ? [ + "HOME", + "HACK_HOME", + "USER", + "LOGNAME", + "TMPDIR", + "PATH", + "XDG_RUNTIME_DIR", + ] + : ["HOME", "HACK_HOME", "USER", "LOGNAME", "TMPDIR", "PATH"] + ).sort() + ); + const result = await runAutostartSubprocess( + [ + process.execPath, + "--eval", + 'process.stdout.write(JSON.stringify(Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith("DOCKER_") || key.startsWith("COMPOSE_") || key.startsWith("HACK_")))))', + ], + { + timeoutMs: 5000, + env: { + ...env, + DOCKER_HOST: "invalid://example", + COMPOSE_PROFILES: "debug", + HACK_GLOBAL_CONFIG_PATH: "/invalid/config", + HACK_RUNTIME_BACKEND: "native", + HACK_ENV_SECRET_KEY: "synthetic-fixture-key", + }, + projectId: "alpha", + } + ); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + HACK_HOME: join(root, "state"), + HACK_NO_INTERACTIVE: "1", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + HACK_AUTOSTART_LOGIN: "1", + HACK_AUTOSTART_PROJECT_ID: "alpha", + }); + await expect(runAutostartAtLogin()).rejects.toThrow( + "explicit absolute service home paths" + ); +}); + +test("Linux service startup uses an invocation-owned user scope and has no direct fallback", async () => { + await seed(["alpha"]); + const project = (await readProjectsRegistry({ registryPath })).projects[0]; + if (!project) { + throw new Error("Missing fixture project"); + } + const invocation = ["/fixture/hack"]; + const args = buildAutostartProjectInvocation({ + invocation, + project, + serviceOrigin: true, + platform: "linux", + }); + expect(args.slice(0, 4)).toEqual([ + "systemd-run", + "--user", + "--scope", + "--quiet", + ]); + expect(args[4]).toMatch(/^--unit=hack-autostart-[a-f0-9-]+$/); + expect(args.slice(5)).toEqual([ + "/fixture/hack", + "up", + "-d", + "--no-interactive", + "--json", + "--path", + project.repoRoot, + ]); + expect( + buildAutostartProjectInvocation({ + invocation, + project, + serviceOrigin: false, + platform: "linux", + })[0] + ).toBe("/fixture/hack"); + expect( + buildAutostartProjectInvocation({ + invocation, + project, + serviceOrigin: true, + platform: "darwin", + })[0] + ).toBe("/fixture/hack"); + expect(() => + buildAutostartProjectInvocation({ + invocation, + project, + serviceOrigin: true, + }) + ).toThrow(); +}); + +test("start timeout sends TERM for cleanup before escalating to KILL", async () => { + const receipt = join(root, "term-received"); + const script = + 'process.on("SIGTERM", async () => { await Bun.write(process.env.HOME + "/term-received", "cleaned"); process.exit(0); }); await Bun.sleep(10000);'; + const result = await runAutostartSubprocess( + [process.execPath, "--eval", script], + { timeoutMs: 300, terminationGraceMs: 500, env: { HOME: root } } + ); + expect(result.reason).toBe("start_timeout"); + expect(await Bun.file(receipt).text()).toBe("cleaned"); + const startedAt = performance.now(); + const ignored = await runAutostartSubprocess( + [ + process.execPath, + "--eval", + 'process.on("SIGTERM", () => {}); await Bun.sleep(10000);', + ], + { timeoutMs: 200, terminationGraceMs: 100, env: { HOME: root } } + ); + expect(ignored.reason).toBe("start_timeout"); + expect(performance.now() - startedAt).toBeLessThan(2000); +}); + +test("login lock cancellations persist only the closed neutral reason vocabulary", () => { + for (const reason of ["paused", "global_paused", "target_changed"]) { + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ + ok: false, + error: { + code: "E_CANCELLED", + detail: { reason }, + message: "synthetic omitted details", + }, + }), + }) + ).toEqual({ status: "skipped", reason }); + } + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ + error: { + code: "E_CANCELLED", + detail: { reason: "synthetic omitted details" }, + }, + }), + }) + ).toEqual({ status: "failed", reason: "start_failed" }); +}); + +test("unrecognized error codes cannot inherit reasons from object prototypes", () => { + for (const code of ["constructor", "__proto__", "toString"]) { + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ error: { code } }), + }) + ).toEqual({ status: "failed", reason: "start_failed" }); + } + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ + error: { code: "E_CANCELLED", detail: { reason: { toString: null } } }, + }), + }) + ).toEqual({ status: "failed", reason: "start_failed" }); +}); diff --git a/tests/daemon-login-autostart.test.ts b/tests/daemon-login-autostart.test.ts new file mode 100644 index 000000000..30a44f079 --- /dev/null +++ b/tests/daemon-login-autostart.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from "bun:test"; +import { reportManualAutostartSkip } from "../src/commands/daemon.ts"; +import { + createLoginAutostart, + isLoginServiceAutostart, +} from "../src/daemon/login-autostart.ts"; +import type { RegisteredProject } from "../src/lib/projects-registry.ts"; + +async function flushWork() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +test("only an explicit login-service origin permits automatic project replay", async () => { + expect(isLoginServiceAutostart({ env: {} })).toBe(false); + expect( + isLoginServiceAutostart({ env: { HACK_DAEMON_LOGIN_SERVICE: "1" } }) + ).toBe(true); + expect( + isLoginServiceAutostart({ env: { HACK_DAEMON_LOGIN_SERVICE: "true" } }) + ).toBe(false); + expect( + isLoginServiceAutostart({ + env: { + HACK_DAEMON_LOGIN_SERVICE: "1", + HACK_DAEMON_AUTOSTART_DISABLE: "1", + }, + }) + ).toBe(false); + let runs = 0; + const replay = createLoginAutostart({ + enabled: false, + run: async () => { + runs += 1; + return { waitingForEngine: false }; + }, + onError: () => { + throw new Error("unexpected"); + }, + }); + replay.start(); + replay.observeEngine(true); + await replay.stop(); + expect(runs).toBe(0); +}); + +test("a service retries an unclaimed engine wait once availability is observed, without overlapping", async () => { + const first = Promise.withResolvers<{ waitingForEngine: boolean }>(); + const second = Promise.withResolvers<{ waitingForEngine: boolean }>(); + let runs = 0; + const replay = createLoginAutostart({ + enabled: true, + run: () => { + runs += 1; + return runs === 1 ? first.promise : second.promise; + }, + onError: () => { + throw new Error("unexpected"); + }, + }); + replay.observeEngine(true); + expect(runs).toBe(0); + replay.start(); + replay.start(); + replay.observeEngine(true); + expect(runs).toBe(1); + first.resolve({ waitingForEngine: true }); + await flushWork(); + replay.observeEngine(false); + expect(runs).toBe(1); + replay.observeEngine(true); + replay.observeEngine(true); + expect(runs).toBe(2); + second.resolve({ waitingForEngine: false }); + await flushWork(); + replay.observeEngine(true); + expect(runs).toBe(2); + await replay.stop(); + replay.observeEngine(true); + expect(runs).toBe(2); +}); + +test("shutdown aborts and awaits the owned login pass", async () => { + const settled = Promise.withResolvers<{ waitingForEngine: boolean }>(); + let signal: AbortSignal | undefined; + const replay = createLoginAutostart({ + enabled: true, + run: (value) => { + signal = value; + return settled.promise; + }, + onError: () => { + throw new Error("unexpected"); + }, + }); + replay.start(); + let stopped = false; + const stop = replay.stop().then(() => { + stopped = true; + }); + await flushWork(); + expect(signal?.aborted).toBe(true); + expect(stopped).toBe(false); + settled.resolve({ waitingForEngine: true }); + await stop; + expect(stopped).toBe(true); +}); + +test("manual start reports skipped login replay only for marked projects", async () => { + const project: RegisteredProject = { + id: "example-id", + name: "example", + repoRoot: "/example/project", + projectDir: "/example/project/.hack", + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + autostart: { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: null, + }, + }; + const notes: string[] = []; + await reportManualAutostartSkip({ + env: {}, + readRegistry: async () => ({ version: 1, projects: [project] }), + note: (message) => notes.push(message), + }); + expect(notes).toHaveLength(1); + expect(notes[0]).toContain("did not run the login autostart pass"); + expect(notes[0]).toContain("hack autostart run --no-interactive"); + await reportManualAutostartSkip({ + env: {}, + readRegistry: async () => ({ version: 1, projects: [] }), + note: (message) => notes.push(message), + }); + await reportManualAutostartSkip({ + env: { HACK_DAEMON_LOGIN_SERVICE: "1" }, + readRegistry: async () => { + throw new Error("service must not inspect markers for this notice"); + }, + note: (message) => notes.push(message), + }); + expect(notes).toHaveLength(1); +}); diff --git a/tests/daemon-systemd.test.ts b/tests/daemon-systemd.test.ts index 3f79163bc..31b15280b 100644 --- a/tests/daemon-systemd.test.ts +++ b/tests/daemon-systemd.test.ts @@ -496,6 +496,8 @@ linuxTest( expect(text).toContain("Restart=on-failure"); expect(text).toContain("WantedBy=default.target"); expect(text).toContain("Environment=HACK_NO_INTERACTIVE=1"); + expect(text).toContain("Environment=HACK_DAEMON_LOGIN_SERVICE=1"); + expect(text).toContain("KillMode=control-group"); expect(extractSystemdExecProgram({ unitText: text ?? "" })).toBe( "/opt/my tools%/hack" ); diff --git a/tests/launchd.test.ts b/tests/launchd.test.ts index a36bc8ba3..51281a791 100644 --- a/tests/launchd.test.ts +++ b/tests/launchd.test.ts @@ -21,6 +21,12 @@ describe("launchd plist generation", () => { expect(plist).toContain("daemon"); expect(plist).toContain("start"); expect(plist).toContain("--foreground"); + expect(plist).toContain( + "HACK_DAEMON_LOGIN_SERVICE\n 1" + ); + expect(plist).toContain( + "HACK_NO_INTERACTIVE\n 1" + ); expect(plist).toContain("RunAtLoad"); expect(plist).toContain(""); expect(plist).toContain("WorkingDirectory"); diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md new file mode 100644 index 000000000..cff0360ff --- /dev/null +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -0,0 +1,46 @@ +# Autostart start/stop admission + +This finite model checks one login child and one foreground `down` targeting the +same Compose project and branch. Both initially stopped and initially running +runtime states are explored. There is one pause publication, one start opportunity, +one shutdown attempt and no later explicit `up` or resume. No fairness or eventual +start/stop guarantee is asserted. + +`NoStartAfterSuccessfulDown` means the earlier login child never issues its start +effect after `down` has returned successfully. The positive configuration also +checks `SuccessfulDownIsStopped` and all variable domains. Admission, the runtime +start effect, child settlement, pause publication, stop and successful return are +separate transitions; they are not collapsed into one atomic command. + +| Model action | Source boundary | Regression mapping | +| --- | --- | --- | +| `DownPause` | `handleDown` publishes pause through `updateAutostartIntent` before waiting for the project lock. | `tests/autostart-lifecycle-intent.test.ts`: source down pauses before waiting for a start and holds admission through runtime shutdown. | +| `LoginLock` / `LoginAdmit` / `LoginRefuse` | `withAutostartCommandLock` and `withAutostartProjectLock` serialize the actual CLI child; `assertAutostartProjectCurrent` rereads trusted intent while admission is held. | The same source-down regression rejects the earlier start after pause and a queued later start after successful down. | +| `ChildStart` / `ChildSettle` | Runtime startup effects execute inside the login child's project lock; the owning command settles before lock release. A child admitted just before pause may finish before down obtains the lock. | `tests/autostart-project-lock.test.ts`: start/stop admission serializes the Compose instance even after registry identity changes. | +| `DownLock` / `DownStop` / `DownReturn` | Foreground down holds the same instance lock through runtime shutdown and successful return. | The source-down regression blocks a queued start while fake shutdown is still running. | +| `ChildCrashOrFailure` / `DownFailure` | Failed commands do not establish successful down; a settled child has no further modeled startup effect. | These are abstract assumptions; the model does not qualify process-tree cancellation or dead-owner recovery. | + +The positive configuration exhausts **41 distinct states** (68 generated), with +two initial states and maximum depth seven. The negative configuration enables +only `ReleaseBeforeChildSettles`: it releases the login lock while the child is +still pending. TLC must fail `NoStartAfterSuccessfulDown` at `ChildStart`, in the +same state containing `holder = "none"`, `paused = TRUE`, `child = "started"`, +`running = TRUE`, `down = "done"` and `lateStart = TRUE`. A parse error, incomplete +successor, unrelated invariant failure, partial search or timeout is not evidence. + +The contract is registered in `scripts/lib/tla-runtime-models.ts` and consumed by +`scripts/check-tla-models.ts`. The standard contract tests automatically exercise +its expected state count and exact negative witness. TLC uses the repository's +checksum-pinned TLA+ 1.7.4 artifact; the local qualification used Java 17, two workers +and a 512 MiB heap. + +The shared project lock is assumed exclusive and continuously owned until the +effect-capable child settles. Pause writes are abstract committed atomic updates. +The model does not prove filesystem durability, registry trust, alias/key mapping, +PID identity or lock reclamation, real engine requests, descendant-process cleanup, +or any late runtime effect surviving termination of the lock-owning CLI child. +In particular, `ChildCrashOrFailure` revokes all remaining child effects; that is a +failure assumption, not proof that an orphaned engine request cannot finish later. +Multiple projects, multiple login attempts, reboot, upgrades, global pause, retries, +network prompts, native backends and real login-service behavior are outside this +model. Component model success does not qualify installed binaries or real login. diff --git a/tests/models/tla/autostart-start-stop/StartStop.tla b/tests/models/tla/autostart-start-stop/StartStop.tla new file mode 100644 index 000000000..5b380de08 --- /dev/null +++ b/tests/models/tla/autostart-start-stop/StartStop.tla @@ -0,0 +1,128 @@ +------------------------------ MODULE StartStop ------------------------------ +EXTENDS Naturals + +CONSTANT EarlyUnlock + +VARIABLES paused, holder, child, down, running, lateStart + +vars == <> + +Init == + /\ paused = FALSE + /\ holder = "none" + /\ child = "queued" + /\ down = "idle" + /\ running \in BOOLEAN + /\ lateStart = FALSE + +LoginLock == + /\ child = "queued" + /\ holder = "none" + /\ holder' = "login" + /\ child' = "checking" + /\ UNCHANGED <> + +LoginAdmit == + /\ holder = "login" + /\ child = "checking" + /\ ~paused + /\ child' = "pending" + /\ UNCHANGED <> + +LoginRefuse == + /\ holder = "login" + /\ child = "checking" + /\ paused + /\ holder' = "none" + /\ child' = "refused" + /\ UNCHANGED <> + +(* Negative control: release admission while the child can still start runtime. *) +ReleaseBeforeChildSettles == + /\ EarlyUnlock + /\ holder = "login" + /\ child = "pending" + /\ holder' = "none" + /\ UNCHANGED <> + +ChildStart == + /\ child = "pending" + /\ child' = "started" + /\ running' = TRUE + /\ lateStart' = (lateStart \/ (down = "done")) + /\ UNCHANGED <> + +ChildSettle == + /\ child = "started" + /\ child' = "settled" + /\ holder' = IF holder = "login" THEN "none" ELSE holder + /\ UNCHANGED <> + +(* A dead lock-owning child has no remaining capability to issue startup effects. *) +ChildCrashOrFailure == + /\ child \in {"checking", "pending", "started"} + /\ child' = "settled" + /\ holder' = IF holder = "login" THEN "none" ELSE holder + /\ UNCHANGED <> + +DownPause == + /\ down = "idle" + /\ paused' = TRUE + /\ down' = "waiting" + /\ UNCHANGED <> + +DownLock == + /\ down = "waiting" + /\ holder = "none" + /\ holder' = "down" + /\ down' = "stopping" + /\ UNCHANGED <> + +DownStop == + /\ down = "stopping" + /\ holder = "down" + /\ running' = FALSE + /\ down' = "returning" + /\ UNCHANGED <> + +DownReturn == + /\ down = "returning" + /\ holder = "down" + /\ down' = "done" + /\ holder' = "none" + /\ UNCHANGED <> + +DownFailure == + /\ down \in {"stopping", "returning"} + /\ holder = "down" + /\ down' = "failed" + /\ holder' = "none" + /\ UNCHANGED <> + +Next == + \/ LoginLock + \/ LoginAdmit + \/ LoginRefuse + \/ ReleaseBeforeChildSettles + \/ ChildStart + \/ ChildSettle + \/ ChildCrashOrFailure + \/ DownPause + \/ DownLock + \/ DownStop + \/ DownReturn + \/ DownFailure + +TypeOK == + /\ paused \in BOOLEAN + /\ holder \in {"none", "login", "down"} + /\ child \in {"queued", "checking", "pending", "started", "settled", "refused"} + /\ down \in {"idle", "waiting", "stopping", "returning", "done", "failed"} + /\ running \in BOOLEAN + /\ lateStart \in BOOLEAN + +NoStartAfterSuccessfulDown == ~lateStart +SuccessfulDownIsStopped == (down = "done") => ~running + +Spec == Init /\ [][Next]_vars +============================================================================= diff --git a/tests/models/tla/autostart-start-stop/negative.cfg b/tests/models/tla/autostart-start-stop/negative.cfg new file mode 100644 index 000000000..549e692bf --- /dev/null +++ b/tests/models/tla/autostart-start-stop/negative.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANT EarlyUnlock = TRUE +INVARIANTS TypeOK NoStartAfterSuccessfulDown +CHECK_DEADLOCK FALSE diff --git a/tests/models/tla/autostart-start-stop/positive.cfg b/tests/models/tla/autostart-start-stop/positive.cfg new file mode 100644 index 000000000..97d45f33b --- /dev/null +++ b/tests/models/tla/autostart-start-stop/positive.cfg @@ -0,0 +1,4 @@ +SPECIFICATION Spec +CONSTANT EarlyUnlock = FALSE +INVARIANTS TypeOK NoStartAfterSuccessfulDown SuccessfulDownIsStopped +CHECK_DEADLOCK FALSE diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index 16ffc5773..52dd0e5f0 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -29,6 +29,7 @@ import { removeProjectAutostart, removeProjectsById, setAutostartGlobalPaused, + setAutostartLoginWaitingForEngine, setProjectAutostart, setProjectAutostartPaused, touchProjectRegistration, @@ -882,3 +883,183 @@ test("stored login arrays exceeding the progress bound globally pause automatic invalid: true, }); }); + +test("first add rebuilds owned writable legacy registrations on a private inode", async () => { + await chmod(registryPath, 0o664); + const writer = await open(registryPath, "r+"); + try { + const before = await writer.stat(); + let receipts = 0; + await setProjectAutostart({ + id: "alpha", + registryPath, + onPermissionsRepaired: () => { + receipts += 1; + }, + }); + expect(receipts).toBe(1); + const published = await stat(registryPath); + expect(published.mode & 0o777).toBe(0o600); + expect(published.ino).not.toBe(before.ino); + const fresh = await readFile(registryPath, "utf8"); + await writer.write("old descriptor edit", 0, "utf8"); + expect(await readFile(registryPath, "utf8")).toBe(fresh); + expect((await read("beta"))?.name).toBe("beta"); + expect((await read())?.autostart).toMatchObject({ paused: false }); + await setProjectAutostart({ + id: "beta", + registryPath, + onPermissionsRepaired: () => { + receipts += 1; + }, + }); + expect(receipts).toBe(1); + } finally { + await writer.close(); + } +}); + +test("first-add repair cannot authorize preexisting marker or global intent", async () => { + for (const field of ["project", "global"]) { + const registry = { + version: 1, + projects: [entry("alpha"), entry("beta")], + ...(field === "global" + ? { autostartGlobal: getDefaultAutostartGlobal() } + : {}), + }; + await writeFile( + registryPath, + JSON.stringify( + field === "project" + ? { + ...registry, + projects: [ + { ...entry("alpha"), autostart: { paused: "unknown" } }, + entry("beta"), + ], + } + : registry + ) + ); + await chmod(registryPath, 0o664); + const before = await readFile(registryPath, "utf8"); + let receipt = false; + await expect( + setProjectAutostart({ + id: "beta", + registryPath, + onPermissionsRepaired: () => { + receipt = true; + }, + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(receipt).toBe(false); + expect(await readFile(registryPath, "utf8")).toBe(before); + expect((await stat(registryPath)).mode & 0o777).toBe(0o664); + } +}); + +test("first-add repair still refuses symlinks and partial registrations", async () => { + const target = join(root, "legacy-target.json"); + await rename(registryPath, target); + await chmod(target, 0o664); + await symlink(target, registryPath); + await expect( + setProjectAutostart({ id: "alpha", registryPath }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + const invalid = join(root, "partial.json"); + await writeFile( + invalid, + JSON.stringify({ + version: 1, + projects: [entry("alpha"), { invalid: true }], + }), + { mode: 0o664 } + ); + await chmod(invalid, 0o664); + await expect( + setProjectAutostart({ id: "alpha", registryPath: invalid }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); +}); + +test("engine waiting is visible during the wait and timeout retains unclaimed pending IDs", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + const run = await beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:00:00.000Z", + pendingIds: ["alpha", "beta"], + waitingForEngine: true, + registryPath, + }); + if (!run?.runId) { + throw new Error("Expected a login run"); + } + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: true, + waitingForEngine: true, + pending: ["alpha", "beta"], + }); + expect( + await setAutostartLoginWaitingForEngine({ + runId: "stale", + waitingForEngine: false, + registryPath, + }) + ).toBe(false); + await finishAutostartLoginRun({ + runId: run.runId, + waitingForEngine: true, + registryPath, + }); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + waitingForEngine: true, + pending: ["alpha", "beta"], + failed: [], + }); + expect((await read())?.autostart?.lastAttemptBootId).toBeNull(); + const resumed = await beginAutostartLoginRun({ + bootId: "boot-a", + startedAt: "2026-01-01T00:10:01.000Z", + pendingIds: ["alpha", "beta"], + waitingForEngine: true, + registryPath, + }); + if (!resumed?.runId) { + throw new Error("Expected resumed login run"); + } + expect( + await setAutostartLoginWaitingForEngine({ + runId: resumed.runId, + waitingForEngine: false, + registryPath, + }) + ).toBe(true); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: true, + waitingForEngine: false, + }); + await finishAutostartLoginRun({ runId: resumed.runId, registryPath }); + expect((await readAutostartGlobal({ registryPath })).loginRun).toMatchObject({ + inProgress: false, + waitingForEngine: false, + pending: [], + }); +}); + +test("older login progress defaults missing engine-wait state to false", async () => { + const global = getDefaultAutostartGlobal(); + const { waitingForEngine: _absent, ...loginRun } = global.loginRun; + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [], + autostartGlobal: { ...global, loginRun }, + }) + ); + expect( + (await readAutostartGlobal({ registryPath })).loginRun.waitingForEngine + ).toBe(false); +}); diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 8a7b2bfb2..2f65ed0f6 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -830,6 +830,7 @@ test("project polling observes fresh autostart progress without a runtime refres runId: "private-run-id", bootId: "private-boot-id", inProgress: true, + waitingForEngine: false, startedAt: "2026-01-01T00:00:00Z", pending: [project.id], started: [], From c75a7b7cab6c043845341774e7b1d7c414a580f3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:03:34 +0000 Subject: [PATCH 06/44] fix(autostart): detect installed routing at its compose path --- docs/always-on-projects.md | 3 ++- src/daemon/autostart.ts | 19 ++++++++++--------- tests/daemon-autostart.test.ts | 10 +++++++++- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index b555ce542..916cf78bc 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -56,7 +56,8 @@ thirty seconds between probes. Configure the service bound with run also accepts `--engine-wait-ms`. While waiting, JSON reports `login_run.waiting_for_engine: true`. An engine timeout consumes no project claims. The service daemon retries when its regular engine observation detects -Docker later in the same boot. Global routing starts only if already installed, +Docker later in the same boot. Global routing starts only if its installed +`caddy/docker-compose.yml` exists in the machine's Hack home, then unpaused projects are considered in name/ID order. Starts are at least 500 milliseconds apart, with at most two in progress. Each uses the running daemon's CLI executable and the ordinary `up -d --no-interactive` path. diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index cc67d0989..c023a450d 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -488,6 +488,15 @@ export function autostartRuntimeRefusal( } } +/** The same installed Caddy Compose location used by global up/install. */ +export async function autostartRoutingInstalled( + hackHome: string +): Promise { + return await Bun.file( + resolve(hackHome, GLOBAL_CADDY_DIR_NAME, GLOBAL_CADDY_COMPOSE_FILENAME) + ).exists(); +} + function productionDependencies( runtime: AutostartRuntimeOptions ): AutostartDependencies { @@ -531,15 +540,7 @@ function productionDependencies( } ) ).exitCode === 0, - routingInstalled: async () => - Bun.file( - resolve( - runtime.hackHome, - GLOBAL_CADDY_DIR_NAME, - GLOBAL_PROJECTS_REGISTRY_FILENAME, - GLOBAL_CADDY_COMPOSE_FILENAME - ) - ).exists(), + routingInstalled: () => autostartRoutingInstalled(runtime.hackHome), startRouting: async (signal) => ( await runAutostartSubprocess( diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index c5ab01721..6d256509e 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -1,10 +1,11 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type AutostartDependencies, autostartComposeModel, + autostartRoutingInstalled, autostartRuntimeRefusal, autostartServiceEnvironment, buildAutostartProjectInvocation, @@ -33,6 +34,13 @@ let events: string[]; let starts: { id: string; at: number }[]; let deps: AutostartDependencies; +test("routing installation probe uses the installed Caddy Compose path", async () => { + expect(await autostartRoutingInstalled(root)).toBe(false); + await mkdir(join(root, "caddy")); + await Bun.write(join(root, "caddy", "docker-compose.yml"), "services: {}\n"); + expect(await autostartRoutingInstalled(root)).toBe(true); +}); + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "hack-autostart-sequence-")); registryPath = join(root, "projects.json"); From f714a6af87e881f7f5b4011910a4391cf90c7afe Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:13:18 +0000 Subject: [PATCH 07/44] fix(registry): reuse the merged registry path accessor --- src/lib/projects-registry.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 7bf554107..eb6e00c8b 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -1601,11 +1601,6 @@ function getRegistryPath(): string { ); } -/** Resolve the machine registry shared by intent, login admission and foreground command locks. */ -export function resolveProjectsRegistryPath(): string { - return getRegistryPath(); -} - function getRegistryLockPath(): string { return resolve(resolveGlobalRegistryRoot(), REGISTRY_LOCK_FILENAME); } From 2839fd79f50b4bfa97ce9aeb103a0f6ba5bc2af6 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:19:43 +0000 Subject: [PATCH 08/44] fix(autostart): reconcile declared replica counts with ordinary up --- docs/always-on-projects.md | 3 ++- src/daemon/autostart.ts | 7 ++++++- tests/daemon-autostart.test.ts | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 916cf78bc..ceaf06a4d 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -72,7 +72,8 @@ with `condition: service_completed_successfully`; other stopped services require `up`. Adoption also requires every service in the selected default-profile model to have a container, including required dependency targets. A missing service runs the normal `up` path. This slice reads the primary Compose file; branch -overrides, explicit Compose file/profile overrides and unsupported composition +overrides, declared `scale` or `deploy.replicas`, explicit Compose file/profile +overrides and unsupported composition cannot prove adoption, so they run ordinary `up` and may repeat startup hooks. No terminal or keychain prompt is allowed. Missing keys, required interaction, diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index c023a450d..77da0f880 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -776,7 +776,12 @@ export function autostartComposeModel( } function isDefaultAutostartService(service: unknown): boolean | null { - if (!isRecord(service) || service.extends !== undefined) { + if ( + !isRecord(service) || + service.extends !== undefined || + service.scale !== undefined || + (isRecord(service.deploy) && service.deploy.replicas !== undefined) + ) { return null; } if (service.profiles === undefined) { diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index 6d256509e..a3dece73b 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -34,6 +34,21 @@ let events: string[]; let starts: { id: string; at: number }[]; let deps: AutostartDependencies; +test("declared replica counts require ordinary up rather than partial adoption", () => { + for (const count of [0, 1, 2, "${REPLICAS}"]) { + for (const settings of [ + { scale: count }, + { deploy: { replicas: count } }, + ]) { + expect( + autostartComposeModel({ + services: { web: { image: "example", ...settings } }, + }) + ).toBeNull(); + } + } +}); + test("routing installation probe uses the installed Caddy Compose path", async () => { expect(await autostartRoutingInstalled(root)).toBe(false); await mkdir(join(root, "caddy")); From a8e59088148de653dd2d5d2de1c6c088ec21b1f3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:36:24 +0000 Subject: [PATCH 09/44] fix(autostart): require reviewed legacy paths before opt-in --- docs/always-on-projects.md | 29 +++- src/commands/autostart.ts | 19 ++- src/commands/project-autostart-intent.ts | 48 ++++-- src/commands/project-autostart-opt-in.ts | 62 ++++++++ src/commands/project.ts | 27 +--- src/daemon/autostart.ts | 30 +++- src/daemon/launchd.ts | 2 +- src/daemon/systemd.ts | 2 + src/lib/projects-registry.ts | 110 +++++++++++++- tests/autostart-cli.test.ts | 8 + tests/autostart-lifecycle-intent.test.ts | 176 ++++++++++++++++++++- tests/daemon-autostart.test.ts | 43 ++++++ tests/daemon-systemd.test.ts | 19 +++ tests/launchd.test.ts | 1 + tests/project-autostart.test.ts | 186 ++++++++++++++++++++++- 15 files changed, 709 insertions(+), 53 deletions(-) create mode 100644 src/commands/project-autostart-opt-in.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index ceaf06a4d..cb5b56647 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -21,7 +21,9 @@ changes the marker; it does not start anything immediately. stop it. Start and stop serialize on a machine-local instance lock: `down` waits for an in-flight login start, then stops it before returning success. The login child checks trusted intent under that lock and immediately before starting -containers. A later `hack up` clears the pause. Pausing does not stop running services: +containers. This auxiliary lock covers login starts and Compose stops, including a stop +after a marker was removed while startup was in flight. Ordinary manual starts +do not hold it, so a long manual image build cannot block `down` on this lock. A later `hack up` clears the pause. Pausing does not stop running services: ```sh hack autostart pause --project sample @@ -61,8 +63,9 @@ Docker later in the same boot. Global routing starts only if its installed then unpaused projects are considered in name/ID order. Starts are at least 500 milliseconds apart, with at most two in progress. Each uses the running daemon's CLI executable and the ordinary `up -d --no-interactive` path. -Children receive a fixed environment allow-list and fixed executable search -path; launching-shell `DOCKER_*`, `COMPOSE_*` and `HACK_*` overrides are not copied. +Children receive a fixed environment allow-list and an executable search +path built from system directories and known Bun, Hack and Docker directories +under the owned home (plus Linux `.local/bin`); launching-shell `DOCKER_*`, `COMPOSE_*` and `HACK_*` overrides are not copied. Hack adds only its explicit internal noninteractive and identity settings. Already-running instances are adopted without repeating startup hooks. An unknown runtime state is skipped rather than taken as proof that the instance is down. @@ -84,7 +87,12 @@ Before trusting intent, Hack opens `projects.json` without following links and requires a regular file owned by the current user, with no group or other write access. On first opt-in, an unmarked legacy registry that is a regular file owned by the user is atomically replaced with a fresh `0600` file, with a one-line -notice. The old inode is never reused, so an already-open writer cannot change +notice. The selected checkout must be independently observed from the current +directory before legacy paths can authorize startup. For first opt-in from a +legacy registry, change into the reviewed checkout and run `hack autostart add` +or `hack up -d --always`. Other copied registrations retain a review requirement +until they are opted in from their own checkout. Trusted named targets continue +to work from other directories. The old inode is never reused, so an already-open writer cannot change the replacement. This migration refuses symlinks, foreign ownership, malformed registries and preexisting intent in an unsafe file. That check repeats at every autostart read and mutation. Unsafe registry permissions refuse startup. Do not repair a formerly writable registry with @@ -218,3 +226,16 @@ policy on their next write. This fails closed but loses opt-ins; avoid mixing versions until the upgrade-handling slice supplies compatibility diagnostics. The service-presence check also cannot yet prove its executable supports autostart; reinstall the service with this CLI after upgrading. + +The fixed login environment does not support an engine available only through +a shell `DOCKER_HOST` override (including some rootless setups). Configure a +working default Docker context for the service account; shell overrides are +never copied. Avoid explicit `autostart run` while a service pass is active: +claims prevent duplicate project attempts, but concurrent passes do not share +one dispatch cap in this slice. + +An explicit upgrade snapshot rollback restores the registry at snapshot time. +With `--overwrite` that can also restore old pauses and boot claims; it does not +merge newer machine intent. Pause automatic starts before such a rollback and +review the restored markers before resuming. Upgrade-aware state reconciliation +is part of HACK-2216. diff --git a/src/commands/autostart.ts b/src/commands/autostart.ts index d48199f0f..46f502046 100644 --- a/src/commands/autostart.ts +++ b/src/commands/autostart.ts @@ -1,3 +1,4 @@ +import { realpath } from "node:fs/promises"; import { relative, resolve } from "node:path"; import { type CliContext, @@ -25,7 +26,8 @@ import { emitCliResult, okResult } from "../lib/cli-result.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; import { resolveUserHomeDir } from "../lib/home-dir.ts"; -import { sanitizeBranchSlug } from "../lib/project.ts"; +import { findProjectContext, sanitizeBranchSlug } from "../lib/project.ts"; +import { ProjectInputSelectionError } from "../lib/project-input-selection.ts"; import { PROJECT_SELECTOR_PATTERN } from "../lib/project-name.ts"; import { getDefaultAutostartGlobal, @@ -215,9 +217,24 @@ const add: CommandHandlerFor = async ({ ctx, args }) => { "Invalid --branch value; use a branch alias containing letters or digits." ); } + // Observe only cwd: an unsafe registry must never redirect permission repair + // into a named checkout the user has not independently selected here. + const observed = await findProjectContext(ctx.cwd).catch((error: unknown) => { + if (error instanceof ProjectInputSelectionError) { + return null; + } + throw error; + }); + const observedProject = observed + ? { + repoRoot: await realpath(observed.projectRoot), + projectDir: await realpath(observed.projectDir), + } + : undefined; const updated = await setProjectAutostart({ id: project.id, branch, + observedProject, onPermissionsRepaired: () => process.stderr.write( "Repaired this machine's unmarked projects registry permissions to 0600 before enabling autostart.\n" diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index cf4aa2ca4..8d5c75893 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -132,6 +132,7 @@ export async function updateNativeAutostartIntent(opts: { /** Resolve the same registered instance for command serialization without native legacy discovery. */ export async function withAutostartCommandLock(opts: { + readonly action: "up" | "down"; readonly cwd: string; readonly path?: string; readonly project?: string; @@ -139,6 +140,9 @@ export async function withAutostartCommandLock(opts: { readonly run: () => Promise; }): Promise { const login = process.env.HACK_AUTOSTART_LOGIN === "1"; + if (!login && opts.action === "up") { + return await opts.run(); + } const branch = opts.branch?.trim() ? sanitizeBranchSlug(opts.branch.trim()) : null; @@ -189,22 +193,11 @@ export async function withAutostartCommandLock(opts: { const projectId = login ? process.env.HACK_AUTOSTART_PROJECT_ID : registration?.id; - if (login && (!projectId || projectId.length > 128)) { - throw new HackCliError({ - code: "E_CANCELLED", - message: "Autostart requires an explicit registered project identity.", - }); - } - if ( - login && - !registry.projects.some( - (entry) => - entry.id === projectId && entry.repoRoot === project.projectRoot - ) - ) { - throw new HackCliError({ - code: "E_CANCELLED", - message: "Autostart project path changed; no start ran.", + if (login) { + assertLoginProjectIdentity({ + projectId, + projects: registry.projects, + root: project.projectRoot, }); } const scope = { @@ -226,6 +219,29 @@ export async function withAutostartCommandLock(opts: { }); } +function assertLoginProjectIdentity(opts: { + readonly projectId?: string; + readonly projects: readonly RegisteredProject[]; + readonly root: string; +}): void { + if (!opts.projectId || opts.projectId.length > 128) { + throw new HackCliError({ + code: "E_CANCELLED", + message: "Autostart requires an explicit registered project identity.", + }); + } + if ( + !opts.projects.some( + (entry) => entry.id === opts.projectId && entry.repoRoot === opts.root + ) + ) { + throw new HackCliError({ + code: "E_CANCELLED", + message: "Autostart project path changed; no start ran.", + }); + } +} + /** Read the original login identity again at the container publication boundary. */ export async function assertLoginAutostartCurrent( branch: string | null, diff --git a/src/commands/project-autostart-opt-in.ts b/src/commands/project-autostart-opt-in.ts new file mode 100644 index 000000000..9749bec7c --- /dev/null +++ b/src/commands/project-autostart-opt-in.ts @@ -0,0 +1,62 @@ +import { CliUsageError } from "../cli/command.ts"; +import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; +import { findProjectContext, type ProjectContext } from "../lib/project.ts"; +import { normalizeProjectName } from "../lib/project-name.ts"; +import { + type ProjectsRegistry, + setProjectAutostart, + upsertProjectRegistration, +} from "../lib/projects-registry.ts"; + +/** Validate legacy paths before an ordinary registration write can make them private. */ +export async function enableAutostartForUp(opts: { + readonly project: ProjectContext; + readonly startDir: string; + readonly selectorName?: string; + readonly branch: string | null; + readonly registry: ProjectsRegistry; +}): Promise { + const { project, branch, registry } = opts; + if (await isLinkedGitWorktree({ repoRoot: project.projectRoot })) { + throw new CliUsageError( + "Autostart currently requires the primary checkout. Run hack up --always there with --branch ." + ); + } + const observed = await findProjectContext(opts.startDir).catch(() => null); + const observedProject = observed + ? { repoRoot: observed.projectRoot, projectDir: observed.projectDir } + : undefined; + const existing = registry.projects.find((entry) => + opts.selectorName + ? normalizeProjectName(entry.name) === opts.selectorName + : entry.repoRoot === project.projectRoot && + entry.projectDir === project.projectDir + ); + const onPermissionsRepaired = () => + process.stderr.write( + "Repaired this machine's unmarked projects registry permissions to 0600 before enabling autostart.\n" + ); + // Validate legacy identity before an upsert could republish its unsafe paths. + if (existing) { + await setProjectAutostart({ + id: existing.id, + branch, + observedProject, + onPermissionsRepaired, + }); + } + const registered = await upsertProjectRegistration({ project }); + if (registered.status === "conflict") { + throw new CliUsageError( + "Project registration conflicts; inspect hack projects before enabling autostart." + ); + } + if (!existing) { + await setProjectAutostart({ + id: registered.project.id, + branch, + observedProject, + onPermissionsRepaired, + }); + } +} diff --git a/src/commands/project.ts b/src/commands/project.ts index 8b71cb273..71a9b97d5 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -249,7 +249,6 @@ import { writeTextFileIfChanged, } from "../lib/fs.ts"; import { ensureHackGitInfoExclude } from "../lib/git-info-exclude.ts"; -import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; import { resolveInstalledGrafanaHost } from "../lib/global-service-host.ts"; import { getString, isRecord } from "../lib/guards.ts"; import { resolveHackInvocation } from "../lib/hack-cli.ts"; @@ -367,7 +366,6 @@ import { } from "../lib/project-shared-helpers.ts"; import { readProjectsRegistry, - setProjectAutostart, setProjectAutostartPaused, upsertProjectRegistration, } from "../lib/projects-registry.ts"; @@ -431,6 +429,7 @@ import { updateNativeAutostartIntent, withAutostartCommandLock, } from "./project-autostart-intent.ts"; +import { enableAutostartForUp } from "./project-autostart-opt-in.ts"; import { prepareDisposableCachePrune, reconcileImplicitDownBranch, @@ -6469,24 +6468,12 @@ async function updateAutostartIntent(opts: { } const branch = resolvedBranch.branch; if (opts.action === "up" && opts.args.options.always) { - if (await isLinkedGitWorktree({ repoRoot: project.projectRoot })) { - throw new CliUsageError( - "Autostart currently requires the primary checkout. Run hack up --always there with --branch ." - ); - } - const registered = await upsertProjectRegistration({ project }); - if (registered.status === "conflict") { - throw new CliUsageError( - "Project registration conflicts; inspect hack projects before enabling autostart." - ); - } - await setProjectAutostart({ - id: registered.project.id, + await enableAutostartForUp({ + project, + startDir: resolveStartDir(opts.ctx, opts.args.options.path), + selectorName: selector?.name, branch, - onPermissionsRepaired: () => - process.stderr.write( - "Repaired this machine's unmarked projects registry permissions to 0600 before enabling autostart.\n" - ), + registry, }); return; } @@ -6529,6 +6516,7 @@ async function handleUp( return await run(); } return await withAutostartCommandLock({ + action: "up", cwd: input.ctx.cwd, path: input.args.options.path, project: input.args.options.project, @@ -7290,6 +7278,7 @@ async function handleDown( // Publish stop intent before waiting for an earlier start to release its lock. await updateAutostartIntent({ ...input, action: "down" }); return await withAutostartCommandLock({ + action: "down", cwd: input.ctx.cwd, path: input.args.options.path, project: input.args.options.project, diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index 77da0f880..fa806bd76 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -900,13 +900,41 @@ export function autostartServiceEnvironment( USER: user.username, LOGNAME: user.username, TMPDIR: "/tmp", - PATH: "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + PATH: autostartServicePath({ + home: runtime.home, + platform: runtime.platform ?? process.platform, + }), ...(process.platform === "linux" ? { XDG_RUNTIME_DIR: `/run/user/${user.uid}` } : {}), }; } +/** Service lookup includes known per-user installs without accepting the caller's PATH. */ +export function autostartServicePath(opts: { + readonly home: string; + readonly platform: NodeJS.Platform; +}): string { + const system = + opts.platform === "darwin" + ? [ + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + ] + : ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + return [ + ...system, + ...(opts.platform === "linux" ? [resolve(opts.home, ".local/bin")] : []), + resolve(opts.home, ".bun/bin"), + resolve(opts.home, ".hack/bin"), + resolve(opts.home, ".docker/bin"), + ].join(":"); +} + /** Own process group, no TTY, no keychain, no recursive daemon startup, bounded output and lifetime. */ export async function runAutostartSubprocess( argv: readonly string[], diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index b514a7a2f..3ea758f95 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -200,7 +200,7 @@ export function renderLaunchdPlist(opts: LaunchdPlistOptions): string { " HOME", ` ${opts.home}`, " PATH", - ` /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${opts.home}/.bun/bin:${opts.home}/.hack/bin`, + ` /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${opts.home}/.bun/bin:${opts.home}/.hack/bin:${opts.home}/.docker/bin`, " ", ]; diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index d6e6d249a..0ccb20a74 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -508,6 +508,7 @@ export function renderSystemdUnit(opts: { "%h/.local/bin", "%h/.bun/bin", "%h/.hack/bin", + "%h/.docker/bin", ].join(":"); return [ "# Managed by hack: written by `hack daemon install`, removed by `hack daemon uninstall`.", @@ -1983,6 +1984,7 @@ export function renderedServicePathDirectories(opts: { resolve(opts.home, ".local/bin"), resolve(opts.home, ".bun/bin"), resolve(opts.home, ".hack/bin"), + resolve(opts.home, ".docker/bin"), ]; } diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index eb6e00c8b..8cb7f08b7 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -105,6 +105,8 @@ export interface RegisteredProject { /** Linked git worktree checkouts of this project seen by the CLI (additive; absent in older registries). */ readonly worktrees?: readonly RegisteredProjectWorktree[]; readonly autostart?: ProjectAutostart; + /** Legacy paths copied from writable storage still need independent review before first opt-in. */ + readonly autostartRequiresReview?: true; } export interface ProjectsRegistry { @@ -403,7 +405,13 @@ async function readProjectsRegistryForMutation(): Promise { return observed.autostartGlobal || observed.projects.some((project) => project.autostart) ? await readProjectsRegistry({ requireAutostartTrust: true }) - : observed; + : ( + await readAutostartMutationRegistry({ + registryPath: getRegistryPath(), + allowLegacyPermissionRepair: true, + requireObservedLegacyProject: false, + }) + ).registry; } type RegistrationUpsertOptions = { @@ -1027,6 +1035,11 @@ export async function setProjectAutostart( opts: AutostartRegistryOptions & { readonly id: string; readonly branch?: string | null; + /** Independently observed current checkout; unsafe legacy paths cannot establish this proof. */ + readonly observedProject?: { + readonly repoRoot: string; + readonly projectDir: string; + }; /** Fired only after rebuilding an owned, unmarked legacy registry on a new private inode. */ readonly onPermissionsRepaired?: () => void; } @@ -1035,6 +1048,7 @@ export async function setProjectAutostart( await mutateProjectAutostart({ ...opts, allowLegacyPermissionRepair: true, + requireObservedProjectReview: true, update: (project) => { const branch = opts.branch ?? null; const old = project.autostart; @@ -1052,7 +1066,8 @@ export async function setProjectAutostart( lastResult: old?.lastResult ?? null, lastAttemptBootId: old?.lastAttemptBootId ?? null, }; - selected = { ...project, autostart }; + const { autostartRequiresReview: _reviewed, ...reviewed } = project; + selected = { ...reviewed, autostart }; return selected; }, }); @@ -1120,6 +1135,7 @@ export async function claimProjectAutostart( update: (project) => { const marker = project.autostart; if ( + project.autostartRequiresReview || !marker || marker.invalid || marker.paused || @@ -1171,6 +1187,11 @@ async function mutateProjectAutostart( readonly id?: string; readonly respectGlobalPause?: boolean; readonly allowLegacyPermissionRepair?: boolean; + readonly requireObservedProjectReview?: boolean; + readonly observedProject?: { + readonly repoRoot: string; + readonly projectDir: string; + }; readonly onPermissionsRepaired?: () => void; readonly update: (project: RegisteredProject) => RegisteredProject; } @@ -1184,11 +1205,25 @@ async function mutateProjectAutostart( const { registry: current, permissionsRepaired } = await readAutostartMutationRegistry({ registryPath, + id: opts.id, + observedProject: opts.observedProject, allowLegacyPermissionRepair: opts.allowLegacyPermissionRepair, }); if (opts.respectGlobalPause && current.autostartGlobal?.paused) { return []; } + if ( + opts.requireObservedProjectReview && + current.projects.some( + (project) => project.id === opts.id && project.autostartRequiresReview + ) + ) { + await assertObservedLegacyRepairProject({ + registry: current, + id: opts.id, + observedProject: opts.observedProject, + }); + } if (opts.id !== undefined) { selectRegisteredProjectById({ projects: current.projects, @@ -1232,12 +1267,19 @@ async function mutateProjectAutostart( /** * An explicit first opt-in may copy current-user-owned unmarked v4 registrations - * to a fresh 0600 inode. Existing automatic-start intent cannot be authenticated + * to a fresh 0600 inode only after independently observed checkout paths match. + * Existing automatic-start intent cannot be authenticated * this way: it requires separate preservation and a reviewed rebuild instead. */ async function readAutostartMutationRegistry(opts: { readonly registryPath: string; readonly allowLegacyPermissionRepair?: boolean; + readonly requireObservedLegacyProject?: boolean; + readonly id?: string; + readonly observedProject?: { + readonly repoRoot: string; + readonly projectDir: string; + }; }): Promise<{ readonly registry: ProjectsRegistry; readonly permissionsRepaired: boolean; @@ -1296,7 +1338,60 @@ async function readAutostartMutationRegistry(opts: { ) { throw new AutostartRegistryTrustError(); } - return { registry, permissionsRepaired: true }; + if (opts.requireObservedLegacyProject !== false) { + await assertObservedLegacyRepairProject({ + registry, + id: opts.id, + observedProject: opts.observedProject, + }); + } + return { + registry: { + ...registry, + projects: registry.projects.map((project) => ({ + ...project, + autostartRequiresReview: true, + })), + }, + permissionsRepaired: true, + }; +} + +async function assertObservedLegacyRepairProject(opts: { + readonly registry: ProjectsRegistry; + readonly id?: string; + readonly observedProject?: { + readonly repoRoot: string; + readonly projectDir: string; + }; +}): Promise { + const refusal = () => + new HackCliError({ + code: "E_PERMISSION", + message: + "A writable legacy registry cannot authorize a named target by itself. Change into the reviewed project checkout and run 'hack up --always' or 'hack autostart add' there. No intent was changed. Values omitted.", + }); + const matches = opts.registry.projects.filter( + (project) => project.id === opts.id + ); + const selected = matches.length === 1 ? matches[0] : undefined; + if (!(selected && opts.observedProject)) { + throw refusal(); + } + try { + const [observedRoot, observedDir, registeredRoot, registeredDir] = + await Promise.all([ + realpath(opts.observedProject.repoRoot), + realpath(opts.observedProject.projectDir), + realpath(selected.repoRoot), + realpath(selected.projectDir), + ]); + if (observedRoot !== registeredRoot || observedDir !== registeredDir) { + throw refusal(); + } + } catch { + throw refusal(); + } } /** Validate every requested ID under ownership before removing any entry. */ @@ -1490,6 +1585,9 @@ function parseProject(value: unknown): RegisteredProject | null { ...(lastSeenAt ? { lastSeenAt } : {}), ...(worktrees.length > 0 ? { worktrees } : {}), ...(autostart ? { autostart } : {}), + ...(value.autostartRequiresReview === true + ? { autostartRequiresReview: true } + : {}), }; } @@ -1626,7 +1724,9 @@ async function writeRegistryAtomic( )}\n`; if ( (registry.autostartGlobal || - registry.projects.some((project) => project.autostart)) && + registry.projects.some( + (project) => project.autostart || project.autostartRequiresReview + )) && Buffer.byteLength(text) > AUTOSTART_REGISTRY_LIMIT_BYTES ) { throw new AutostartRegistryTrustError(); diff --git a/tests/autostart-cli.test.ts b/tests/autostart-cli.test.ts index 5886394bb..84c10cb20 100644 --- a/tests/autostart-cli.test.ts +++ b/tests/autostart-cli.test.ts @@ -49,6 +49,11 @@ const viewMock = await registerScopedModuleMock({ specifier: "../src/lib/autostart-view.ts", overrides: { readAutostartServiceReady: async () => false }, }); +const projectMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/project.ts", + overrides: { findProjectContext: async () => null }, +}); const linkedRoots = new Set(); const gitMock = await registerScopedModuleMock({ importerPath: import.meta.path, @@ -91,6 +96,7 @@ const registryMock = await registerScopedModuleMock({ }, }); beforeAll(() => { + projectMock.activate(); startupMock.activate(); viewMock.activate(); registryMock.activate(); @@ -101,6 +107,7 @@ afterAll(() => { registryMock.deactivate(); viewMock.deactivate(); startupMock.deactivate(); + projectMock.deactivate(); }); beforeEach(() => { mutations.length = 0; @@ -145,6 +152,7 @@ test("autostart add selects current registered project and passes branch without { id: "example-id", branch: "preview", + observedProject: undefined, onPermissionsRepaired: expect.any(Function), }, ]); diff --git a/tests/autostart-lifecycle-intent.test.ts b/tests/autostart-lifecycle-intent.test.ts index 2ba121bbb..a669ac61f 100644 --- a/tests/autostart-lifecycle-intent.test.ts +++ b/tests/autostart-lifecycle-intent.test.ts @@ -1,5 +1,13 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { classifyAutostartExit } from "../src/daemon/autostart.ts"; @@ -332,3 +340,169 @@ test("a login child cannot resume a manually paused project or substitute a diff expect(mismatched.stdout).toContain("project path changed"); expect((await marker()).paused).toBe(false); }); + +async function legacyWritableRegistry(redirect = false) { + await writeRegistry(false); + const path = join(root, "home", ".hack", "projects.json"); + const registry = await Bun.file(path).json(); + Reflect.deleteProperty(registry.projects[0], "autostart"); + if (redirect) { + const other = join(root, "redirected"); + await mkdir(join(other, ".hack"), { recursive: true }); + await writeFile( + join(other, ".hack", "hack.config.json"), + '{"name":"sample"}' + ); + await writeFile( + join(other, ".hack", "docker-compose.yml"), + "services:\n app:\n image: example.invalid/app\n" + ); + registry.projects[0].repoRoot = other; + registry.projects[0].projectDir = join(other, ".hack"); + } + await writeFile(path, JSON.stringify(registry)); + await chmod(path, 0o664); + return { path, before: await Bun.file(path).text() }; +} + +test("named add refuses laundering an unsafe legacy registry redirected away from cwd", async () => { + const { path, before } = await legacyWritableRegistry(true); + const result = await run([ + "autostart", + "add", + "--project", + "sample", + "--json", + ]); + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: "E_PERMISSION" }, + }); + expect(await Bun.file(path).text()).toBe(before); + expect((await stat(path)).mode & 0o777).toBe(0o664); + expect(await marker()).toBeUndefined(); +}); + +test("named add outside a project refuses unsafe first opt-in but preserves trusted named selection", async () => { + const { path, before } = await legacyWritableRegistry(); + const refused = await run( + ["autostart", "add", "--project", "sample", "--json"], + false, + root + ); + expect(refused.exitCode).not.toBe(0); + expect(JSON.parse(refused.stdout)).toMatchObject({ + ok: false, + error: { code: "E_PERMISSION" }, + }); + expect(await Bun.file(path).text()).toBe(before); + await chmod(path, 0o600); + const trusted = await run( + ["autostart", "add", "--project", "sample", "--json"], + false, + root + ); + expect(trusted.exitCode, trusted.stdout + trusted.stderr).toBe(0); + expect((await marker()).paused).toBe(false); +}); + +test("reviewed cwd legacy first opt-in rebuilds a private registry and reports its repair", async () => { + const { path } = await legacyWritableRegistry(); + const result = await run([ + "autostart", + "add", + "--project", + "sample", + "--json", + ]); + expect(result.exitCode, result.stdout + result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + data: { action: "add" }, + }); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(result.stderr).toContain("registry permissions to 0600"); + expect((await marker()).paused).toBe(false); +}); + +test("manual up bypasses the auxiliary lock while every legacy down retains serialization", async () => { + const locks = join(root, "home", ".hack", "autostart-locks"); + await mkdir(locks, { mode: 0o777 }); + await chmod(locks, 0o777); + const path = join(root, "home", ".hack", "projects.json"); + for (const marked of [false, true]) { + await writeRegistry(false); + if (!marked) { + const registry = await Bun.file(path).json(); + Reflect.deleteProperty(registry.projects[0], "autostart"); + await writeFile(path, JSON.stringify(registry)); + } + const up = await run(["up", "--json", "-d"]); + expect(up.stdout + up.stderr).not.toContain("lock directory is unsafe"); + const down = await run(["down", "--json"]); + expect(down.stdout + down.stderr).toContain("lock directory is unsafe"); + } +}); + +test("up --always cannot launder a redirected unsafe named registration before opt-in", async () => { + const { path, before } = await legacyWritableRegistry(true); + const result = await run([ + "up", + "--always", + "--project", + "sample", + "--json", + "-d", + ]); + expect(result.exitCode).not.toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { code: "E_PERMISSION" }, + }); + expect(await Bun.file(path).text()).toBe(before); + expect((await stat(path)).mode & 0o777).toBe(0o664); +}); + +test("down still waits for an admitted login child after its marker is removed", async () => { + await writeRegistry(false); + const registryPath = join(root, "home", ".hack", "projects.json"); + const scope = { projectId: "sample", composeProject: "sample", branch: null }; + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const start = withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + entered.resolve(); + await release.promise; + await assertAutostartProjectCurrent({ scope, registryPath }); + }, + }).then( + () => ({ refused: false }), + (error: unknown) => ({ refused: true, error }) + ); + await entered.promise; + const registry = await Bun.file(registryPath).json(); + Reflect.deleteProperty(registry.projects[0], "autostart"); + await writeFile(registryPath, JSON.stringify(registry)); + let finished = false; + const down = run(["down", "--json"]).then((value) => { + finished = true; + return value; + }); + try { + await Bun.sleep(650); + expect(finished).toBe(false); + release.resolve(); + expect(await start).toMatchObject({ + refused: true, + error: { code: "E_CANCELLED" }, + }); + await down; + expect(finished).toBe(true); + } finally { + release.resolve(); + await Promise.allSettled([start, down]); + } +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index a3dece73b..bac5d3f78 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -8,6 +8,7 @@ import { autostartRoutingInstalled, autostartRuntimeRefusal, autostartServiceEnvironment, + autostartServicePath, buildAutostartProjectInvocation, classifyAutostartContainers, classifyAutostartExit, @@ -990,3 +991,45 @@ test("unrecognized error codes cannot inherit reasons from object prototypes", ( }) ).toEqual({ status: "failed", reason: "start_failed" }); }); + +test("service PATH derives known user install directories from its owned home and ignores hostile shell PATH", () => { + const previous = process.env.PATH; + try { + process.env.PATH = "/hostile/shell/bin"; + const linux = autostartServiceEnvironment({ + home: root, + hackHome: join(root, "state"), + platform: "linux", + }); + expect(linux.PATH).toBe( + autostartServicePath({ home: root, platform: "linux" }) + ); + expect(linux.PATH?.split(":")).toEqual([ + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + join(root, ".local/bin"), + join(root, ".bun/bin"), + join(root, ".hack/bin"), + join(root, ".docker/bin"), + ]); + const mac = autostartServicePath({ home: root, platform: "darwin" }).split( + ":" + ); + expect(mac).toContain("/opt/homebrew/bin"); + expect(mac).toContain(join(root, ".bun/bin")); + expect(mac).toContain(join(root, ".hack/bin")); + expect(mac).toContain(join(root, ".docker/bin")); + expect(mac).not.toContain(join(root, ".local/bin")); + expect(linux.PATH).not.toContain("/hostile/shell/bin"); + expect(mac).not.toContain("/hostile/shell/bin"); + } finally { + if (previous === undefined) { + process.env.PATH = undefined; + } else { + process.env.PATH = previous; + } + } +}); diff --git a/tests/daemon-systemd.test.ts b/tests/daemon-systemd.test.ts index 31b15280b..8b1d3d3ca 100644 --- a/tests/daemon-systemd.test.ts +++ b/tests/daemon-systemd.test.ts @@ -52,6 +52,7 @@ import { programFileProblem, readSystemdServicePathDirectories, readSystemdServiceStatus, + renderedServicePathDirectories, renderSystemdUnit, resolveSystemdAccess, resolveSystemdUnitPath, @@ -498,6 +499,24 @@ linuxTest( expect(text).toContain("Environment=HACK_NO_INTERACTIVE=1"); expect(text).toContain("Environment=HACK_DAEMON_LOGIN_SERVICE=1"); expect(text).toContain("KillMode=control-group"); + expect(text).toContain( + "%h/.local/bin:%h/.bun/bin:%h/.hack/bin:%h/.docker/bin" + ); + expect( + renderedServicePathDirectories({ + hackBinPath: "/opt/hack", + home: "/example/user", + systemDirectories: ["/usr/bin", "/bin"], + }) + ).toEqual([ + "/opt", + "/usr/bin", + "/bin", + "/example/user/.local/bin", + "/example/user/.bun/bin", + "/example/user/.hack/bin", + "/example/user/.docker/bin", + ]); expect(extractSystemdExecProgram({ unitText: text ?? "" })).toBe( "/opt/my tools%/hack" ); diff --git a/tests/launchd.test.ts b/tests/launchd.test.ts index 51281a791..7df2cae70 100644 --- a/tests/launchd.test.ts +++ b/tests/launchd.test.ts @@ -95,6 +95,7 @@ describe("launchd plist generation", () => { expect(plist).toContain("/bin"); expect(plist).toContain(".bun/bin"); expect(plist).toContain(".hack/bin"); + expect(plist).toContain("/Users/testuser/.docker/bin"); }); test("plist is valid XML structure", () => { diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index 52dd0e5f0..80a6ff38c 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -550,17 +550,17 @@ test("fresh registry replacement isolates a writer retained on the preserved uns } }); -async function createTrustProject() { - const projectDir = join(root, "alpha", ".hack"); +async function createTrustProject(name = "alpha") { + const projectDir = join(root, name, ".hack"); await mkdir(projectDir, { recursive: true }); const configFile = join(projectDir, "hack.config.json"); const composeFile = join(projectDir, "docker-compose.yml"); const envFile = join(projectDir, ".env"); - await writeFile(configFile, JSON.stringify({ name: "alpha" })); + await writeFile(configFile, JSON.stringify({ name })); await writeFile(composeFile, "services: {}\n"); await writeFile(envFile, ""); return { - projectRoot: join(root, "alpha"), + projectRoot: join(root, name), projectDir, projectDirName: ".hack" as const, composeFile, @@ -590,7 +590,15 @@ test("ordinary writes retain trusted intent and can upgrade unmarked legacy perm await chmod(registryPath, 0o664); expect((await upsertProjectRegistration({ project })).status).toBe("updated"); expect((await stat(registryPath)).mode & 0o777).toBe(0o600); - await setProjectAutostart({ id: "alpha", branch: "preview", registryPath }); + await setProjectAutostart({ + id: "alpha", + branch: "preview", + registryPath, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, + }); await upsertProjectRegistration({ project, nowIso: "2027-01-01T00:00:00.000Z", @@ -885,6 +893,7 @@ test("stored login arrays exceeding the progress bound globally pause automatic }); test("first add rebuilds owned writable legacy registrations on a private inode", async () => { + const project = await createTrustProject(); await chmod(registryPath, 0o664); const writer = await open(registryPath, "r+"); try { @@ -893,6 +902,10 @@ test("first add rebuilds owned writable legacy registrations on a private inode" await setProjectAutostart({ id: "alpha", registryPath, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, onPermissionsRepaired: () => { receipts += 1; }, @@ -906,9 +919,14 @@ test("first add rebuilds owned writable legacy registrations on a private inode" expect(await readFile(registryPath, "utf8")).toBe(fresh); expect((await read("beta"))?.name).toBe("beta"); expect((await read())?.autostart).toMatchObject({ paused: false }); + const other = await createTrustProject("beta"); await setProjectAutostart({ id: "beta", registryPath, + observedProject: { + repoRoot: other.projectRoot, + projectDir: other.projectDir, + }, onPermissionsRepaired: () => { receipts += 1; }, @@ -919,6 +937,164 @@ test("first add rebuilds owned writable legacy registrations on a private inode" } }); +test("writable legacy first add requires an independent current-checkout observation", async () => { + await createTrustProject(); + await chmod(registryPath, 0o664); + const before = await readFile(registryPath, "utf8"); + await expect( + setProjectAutostart({ id: "alpha", registryPath }) + ).rejects.toThrow("Change into the reviewed project checkout"); + expect(await readFile(registryPath, "utf8")).toBe(before); + expect((await stat(registryPath)).mode & 0o777).toBe(0o664); +}); + +test("writable legacy first add refuses tampered root or project directory", async () => { + const project = await createTrustProject(); + const otherRoot = join(root, "other-project"); + const otherDir = join(otherRoot, ".hack"); + await mkdir(otherDir, { recursive: true }); + const observedProject = { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }; + for (const tampered of [ + { ...entry("alpha"), repoRoot: otherRoot, projectDir: otherDir }, + { ...entry("alpha"), projectDir: otherDir }, + ]) { + await writeFile( + registryPath, + JSON.stringify({ version: 1, projects: [tampered, entry("beta")] }) + ); + await chmod(registryPath, 0o664); + const before = await readFile(registryPath, "utf8"); + let receipt = false; + await expect( + setProjectAutostart({ + id: "alpha", + registryPath, + observedProject, + onPermissionsRepaired: () => { + receipt = true; + }, + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(receipt).toBe(false); + expect(await readFile(registryPath, "utf8")).toBe(before); + expect((await stat(registryPath)).mode & 0o777).toBe(0o664); + } +}); + +test("trusted named targets do not require a current-checkout observation", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + expect((await read())?.autostart?.paused).toBe(false); +}); + +test("repair preserves review provenance for every other copied legacy registration", async () => { + const first = await createTrustProject(); + const secondary = await createTrustProject("secondary"); + await writeFile(secondary.configFile, JSON.stringify({ name: "beta" })); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [ + entry("alpha"), + { + ...entry("beta"), + repoRoot: secondary.projectRoot, + projectDir: secondary.projectDir, + }, + ], + }) + ); + await chmod(registryPath, 0o664); + await setProjectAutostart({ + id: "alpha", + registryPath, + observedProject: { + repoRoot: first.projectRoot, + projectDir: first.projectDir, + }, + }); + expect((await read())?.autostartRequiresReview).toBeUndefined(); + expect((await read("beta"))?.autostartRequiresReview).toBe(true); + await upsertProjectRegistration({ project: secondary }); + await touchProjectRegistration({ + project: secondary, + nowIso: "2027-01-01T00:00:00.000Z", + }); + expect((await read("beta"))?.autostartRequiresReview).toBe(true); + const before = await readFile(registryPath, "utf8"); + await expect( + setProjectAutostart({ id: "beta", registryPath }) + ).rejects.toThrow("Change into the reviewed project checkout"); + expect(await readFile(registryPath, "utf8")).toBe(before); + await setProjectAutostart({ + id: "beta", + registryPath, + observedProject: { + repoRoot: secondary.projectRoot, + projectDir: secondary.projectDir, + }, + }); + expect((await read("beta"))?.autostartRequiresReview).toBeUndefined(); + expect((await read("beta"))?.autostart?.paused).toBe(false); +}); + +test("ordinary legacy republication records review provenance before later named opt-in", async () => { + const project = await createTrustProject(); + await chmod(registryPath, 0o664); + await upsertProjectRegistration({ project }); + expect((await stat(registryPath)).mode & 0o777).toBe(0o600); + expect((await read())?.autostartRequiresReview).toBe(true); + expect((await read("beta"))?.autostartRequiresReview).toBe(true); + await expect( + setProjectAutostart({ id: "alpha", registryPath }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await setProjectAutostart({ + id: "alpha", + registryPath, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, + }); + expect((await read())?.autostartRequiresReview).toBeUndefined(); + expect((await read("beta"))?.autostartRequiresReview).toBe(true); +}); + +test("an existing marker cannot bypass retained checkout-review provenance", async () => { + const project = await createTrustProject(); + await setProjectAutostart({ id: "alpha", registryPath }); + const registry = await readProjectsRegistry({ registryPath }); + await writeFile( + registryPath, + JSON.stringify({ + ...registry, + projects: registry.projects.map((entry) => + entry.id === "alpha" + ? { ...entry, autostartRequiresReview: true } + : entry + ), + }) + ); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); + await setProjectAutostart({ + id: "alpha", + registryPath, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, + }); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).not.toBeNull(); +}); + test("first-add repair cannot authorize preexisting marker or global intent", async () => { for (const field of ["project", "global"]) { const registry = { From 5655076606b130aa0b3de6b1282a3c0f86d2b3de Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:56:43 +0000 Subject: [PATCH 10/44] fix(autostart): pin reconciled stop targets before intent and locking --- docs/always-on-projects.md | 14 +- src/commands/project-autostart-intent.ts | 176 +++++++-- src/commands/project.ts | 65 +++- src/lib/projects-registry.ts | 41 ++- tests/autostart-down-target.test.ts | 338 ++++++++++++++++++ .../tla/autostart-start-stop/COVERAGE.md | 2 +- tests/project-autostart.test.ts | 76 ++++ 7 files changed, 647 insertions(+), 65 deletions(-) create mode 100644 tests/autostart-down-target.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index cb5b56647..4ec4f2a0d 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -18,7 +18,9 @@ a branch instance uses the primary checkout's code, not a different Git checkout changes the marker; it does not start anything immediately. `hack down` pauses autostart for the selected marked instance before attempting to -stop it. Start and stop serialize on a machine-local instance lock: `down` waits +stop it. For a renamed linked-worktree branch, ownership reconciliation selects +the existing instance before its pause is written; the same selected branch is +used for the start/stop lock and teardown. Start and stop serialize on a machine-local instance lock: `down` waits for an in-flight login start, then stops it before returning success. The login child checks trusted intent under that lock and immediately before starting containers. This auxiliary lock covers login starts and Compose stops, including a stop @@ -94,7 +96,11 @@ or `hack up -d --always`. Other copied registrations retain a review requirement until they are opted in from their own checkout. Trusted named targets continue to work from other directories. The old inode is never reused, so an already-open writer cannot change the replacement. This migration refuses symlinks, foreign ownership, malformed -registries and preexisting intent in an unsafe file. That check repeats at every autostart read and mutation. Unsafe registry +registries and preexisting intent in an unsafe file. Ordinary registration and +prune operations keep their prior behavior for an unmarked registry, including +replacing a registry symlink itself with a regular file while leaving its target +unchanged. They retain review requirements on copied paths; this does not +authorize autostart. That check repeats at every autostart read and mutation. Unsafe registry permissions refuse startup. Do not repair a formerly writable registry with in-place `chmod`: another user's already-open writer descriptor remains writable. Preserve the old file outside the active registry, start with a fresh registry, @@ -236,6 +242,6 @@ one dispatch cap in this slice. An explicit upgrade snapshot rollback restores the registry at snapshot time. With `--overwrite` that can also restore old pauses and boot claims; it does not -merge newer machine intent. Pause automatic starts before such a rollback and -review the restored markers before resuming. Upgrade-aware state reconciliation +merge newer machine intent. An overwrite rollback can undo a global pause as well. Review and reapply the +restored machine intent before allowing another login pass. Upgrade-aware state reconciliation is part of HACK-2216. diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 8d5c75893..7f6ad25c4 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -30,11 +30,138 @@ import { import { discoverProjectInputs } from "../lib/project-input-selection.ts"; import { normalizeProjectName } from "../lib/project-name.ts"; import { + type ProjectsRegistry, type RegisteredProject, readProjectsRegistry, + selectRegisteredProjectByName, setProjectAutostartPaused, } from "../lib/projects-registry.ts"; +import { readRuntimeProjects } from "../lib/runtime-projects.ts"; +import { + type DownSafetyNotice, + reconcileImplicitDownBranch, +} from "./project-down-safety.ts"; + +export interface AutostartDownTarget { + readonly branch: string | null; +} +interface AutostartCommandSelection { + readonly cwd: string; + readonly path?: string; + readonly project?: string; + readonly branch?: string; +} + +/** Resolve once before pause, locking and shutdown; an owned base target is explicit null. */ +export async function resolveFinalAutostartDownTarget( + opts: AutostartCommandSelection & { + readonly writeNotice?: (notice: DownSafetyNotice) => void; + }, + deps?: { + readonly readProjectsRegistry?: typeof readProjectsRegistry; + readonly readRuntimeProjects?: typeof readRuntimeProjects; + readonly isLocalTarget?: (project: ProjectContext) => Promise; + } +): Promise { + const registry = await (deps?.readProjectsRegistry ?? readProjectsRegistry)(); + const context = await discoverAutostartCommandContext({ ...opts, registry }); + if (!context) { + return undefined; + } + const { project } = context; + const cfg = await readProjectConfig(project); + const raw = opts.branch?.trim(); + const explicitBranch = raw ? sanitizeBranchSlug(raw) || "branch" : null; + const effective = await resolveEffectiveBranch({ + explicitBranch, + projectRoot: project.projectRoot, + autoBranchEnabled: resolveWorktreeAutoBranch(cfg), + }); + if (effective.source === "detached-worktree") { + throw new CliUsageError( + "Detached linked worktree requires an explicit --branch." + ); + } + if (explicitBranch !== null || effective.branch === null) { + return { branch: effective.branch }; + } + if (deps?.isLocalTarget && !(await deps.isLocalTarget(project))) { + return undefined; + } + if (effective.source === "worktree" && effective.branch) { + opts.writeNotice?.({ + level: "info", + message: `Linked worktree detected → using branch instance "${effective.branch}" (override with --branch , disable with worktree.auto_branch=false)`, + }); + } + const runtime = await (deps?.readRuntimeProjects ?? readRuntimeProjects)({ + includeGlobal: false, + }); + if (!runtime.ok) { + throw new HackCliError({ + code: "E_DOCKER_UNAVAILABLE", + message: `Cannot inspect runtime ownership before down: ${runtime.error}`, + }); + } + return { + branch: reconcileImplicitDownBranch({ + baseProjectName: await resolveComposeProjectName({ project, cfg }), + branch: effective.branch, + projectDir: project.projectDir, + runtime: runtime.runtime, + writeNotice: opts.writeNotice ?? (() => undefined), + }), + }; +} + +/** Shared legacy discovery never crosses native inputs or guesses a duplicate named registration. */ +async function discoverAutostartCommandContext( + opts: AutostartCommandSelection & { readonly registry: ProjectsRegistry } +): Promise< + | { + readonly project: ProjectContext; + readonly registration: RegisteredProject | undefined; + } + | undefined +> { + const requested = resolve(opts.cwd, opts.path ?? "."); + const cwd = await realpath(requested).catch(() => requested); + const selected = + opts.project === undefined + ? undefined + : parseProjectSelectorOption({ projectOpt: opts.project }); + const registration = selected + ? (selectRegisteredProjectByName({ + projects: opts.registry.projects, + name: selected.name, + }) ?? undefined) + : opts.registry.projects + .filter((entry) => + [ + entry.repoRoot, + ...(entry.worktrees?.map((tree) => tree.path) ?? []), + ].some((root) => cwd === root || cwd.startsWith(`${root}/`)) + ) + .sort((a, b) => b.repoRoot.length - a.repoRoot.length)[0]; + if (selected && !registration) { + return undefined; + } + const inputs = await discoverProjectInputs({ + startDir: selected && registration ? registration.repoRoot : cwd, + }); + if (!inputs || inputs.kind !== "legacy") { + return undefined; + } + const project = await findProjectContextAtRoot({ + projectRoot: inputs.projectRoot, + projectDirName: inputs.composeDirectories.includes(".hack") + ? ".hack" + : ".dev", + }); + return project ? { project, registration } : undefined; +} + /** Login must never continue with a legacy environment requiring migration. */ export async function assertAutostartEnvironment(opts: { readonly project: ProjectContext; @@ -133,6 +260,7 @@ export async function updateNativeAutostartIntent(opts: { /** Resolve the same registered instance for command serialization without native legacy discovery. */ export async function withAutostartCommandLock(opts: { readonly action: "up" | "down"; + readonly resolvedDownTarget?: AutostartDownTarget; readonly cwd: string; readonly path?: string; readonly project?: string; @@ -149,47 +277,19 @@ export async function withAutostartCommandLock(opts: { const registry = await readProjectsRegistry( login ? { requireAutostartTrust: true } : undefined ); - const requested = resolve(opts.cwd, opts.path ?? "."); - const cwd = await realpath(requested).catch(() => requested); - const selected = - opts.project === undefined - ? undefined - : parseProjectSelectorOption({ projectOpt: opts.project }); - const registrations = registry.projects - .filter((project) => - selected - ? normalizeProjectName(project.name) === selected.name - : [ - project.repoRoot, - ...(project.worktrees?.map((tree) => tree.path) ?? []), - ].some((root) => cwd === root || cwd.startsWith(`${root}/`)) - ) - .sort((a, b) => b.repoRoot.length - a.repoRoot.length); - const registration = registrations[0]; - if (selected && !registration) { - return await opts.run(); - } - const inputs = await discoverProjectInputs({ - startDir: selected && registration ? registration.repoRoot : cwd, - }); - if (!inputs || inputs.kind !== "legacy") { - return await opts.run(); - } - const project = await findProjectContextAtRoot({ - projectRoot: inputs.projectRoot, - projectDirName: inputs.composeDirectories.includes(".hack") - ? ".hack" - : ".dev", - }); - if (!project) { + const context = await discoverAutostartCommandContext({ ...opts, registry }); + if (!context) { return await opts.run(); } + const { project, registration } = context; const cfg = await readProjectConfig(project); - const effective = await resolveEffectiveBranch({ - explicitBranch: branch, - projectRoot: project.projectRoot, - autoBranchEnabled: login ? false : resolveWorktreeAutoBranch(cfg), - }); + const effective = + opts.resolvedDownTarget ?? + (await resolveEffectiveBranch({ + explicitBranch: branch, + projectRoot: project.projectRoot, + autoBranchEnabled: login ? false : resolveWorktreeAutoBranch(cfg), + })); const projectId = login ? process.env.HACK_AUTOSTART_PROJECT_ID : registration?.id; diff --git a/src/commands/project.ts b/src/commands/project.ts index 71a9b97d5..238a508f8 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -426,6 +426,7 @@ import { assertAutostartEnvironment, assertLoginAutostartCurrent, findAutostartRegistration, + resolveFinalAutostartDownTarget, updateNativeAutostartIntent, withAutostartCommandLock, } from "./project-autostart-intent.ts"; @@ -724,7 +725,10 @@ const serviceLifecyclePositionals = [ type InitArgs = CommandArgs; type UpArgs = CommandArgs; -type DownArgs = CommandArgs; +type DownArgs = CommandArgs & { + /** Pin the reconciled instance through pause, admission and teardown. */ + readonly resolvedAutostartBranch?: { readonly branch: string | null }; +}; type RestartArgs = CommandArgs< typeof restartOptions, typeof serviceLifecyclePositionals @@ -6404,6 +6408,7 @@ async function restartNativeImageProject({ async function updateAutostartIntent(opts: { readonly ctx: CliContext; readonly action: "up" | "down"; + readonly resolvedDownTarget?: { readonly branch: string | null }; readonly args: { readonly options: { readonly path?: string; @@ -6466,7 +6471,9 @@ async function updateAutostartIntent(opts: { "Detached linked worktree requires an explicit --branch." ); } - const branch = resolvedBranch.branch; + const branch = opts.resolvedDownTarget + ? opts.resolvedDownTarget.branch + : resolvedBranch.branch; if (opts.action === "up" && opts.args.options.always) { await enableAutostartForUp({ project, @@ -7276,14 +7283,48 @@ async function handleDown( ): Promise { parseRequestedTarget({ raw: input.args.options.target }); // Publish stop intent before waiting for an earlier start to release its lock. - await updateAutostartIntent({ ...input, action: "down" }); + const resolvedDownTarget = + process.env.HACK_RUNTIME_BACKEND === "native" + ? undefined + : await resolveFinalAutostartDownTarget( + { + cwd: input.ctx.cwd, + path: input.args.options.path, + project: input.args.options.project, + branch: input.args.options.branch, + writeNotice: (notice) => + writeDownNotice({ + json: input.args.options.json === true, + ...notice, + }), + }, + { + isLocalTarget: async (project) => + ( + await resolveProjectLifecycleTarget({ + project, + requestedTarget: input.args.options.target, + }) + ).resolvedTarget === "local", + } + ); + const pinned = { + ...input, + args: { ...input.args, resolvedAutostartBranch: resolvedDownTarget }, + }; + await updateAutostartIntent({ + ...pinned, + action: "down", + resolvedDownTarget, + }); return await withAutostartCommandLock({ action: "down", cwd: input.ctx.cwd, path: input.args.options.path, project: input.args.options.project, branch: input.args.options.branch, - run: () => handleDownUnlocked(input), + resolvedDownTarget, + run: () => handleDownUnlocked(pinned), }); } @@ -7485,11 +7526,13 @@ async function runDownCommand({ const envName = resolveRequestedEnvName({ envOption: args.options.env, }); - let branch = await resolveEffectiveBranchForCommand({ - project, - branchOption: args.options.branch, - noticeToStderr: json, - }); + let branch = args.resolvedAutostartBranch + ? args.resolvedAutostartBranch.branch + : await resolveEffectiveBranchForCommand({ + project, + branchOption: args.options.branch, + noticeToStderr: json, + }); const profiles = parseCsvList(args.options.profile); if (args.options.removeData) { throw new CliUsageError( @@ -7553,7 +7596,9 @@ async function runDownCommand({ const baseProjectName = await resolveComposeProjectName({ project, cfg }); let runtime: readonly RuntimeProject[] | null = null; const implicitLinkedWorktreeTarget = - resolveBranchSlug(args.options.branch) === null && branch !== null; + !args.resolvedAutostartBranch && + resolveBranchSlug(args.options.branch) === null && + branch !== null; if (implicitLinkedWorktreeTarget || pruneCaches) { const runtimeResult = await readRuntimeProjects({ includeGlobal: false }); if (!runtimeResult.ok) { diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 8cb7f08b7..4f1b8187b 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -402,16 +402,35 @@ export async function readProjectsRegistryStrict( */ async function readProjectsRegistryForMutation(): Promise { const observed = await readProjectsRegistry(); - return observed.autostartGlobal || + if ( + observed.autostartGlobal || observed.projects.some((project) => project.autostart) - ? await readProjectsRegistry({ requireAutostartTrust: true }) - : ( - await readAutostartMutationRegistry({ - registryPath: getRegistryPath(), - allowLegacyPermissionRepair: true, - requireObservedLegacyProject: false, - }) - ).registry; + ) { + return await readProjectsRegistry({ requireAutostartTrust: true }); + } + try { + return ( + await readAutostartMutationRegistry({ + registryPath: getRegistryPath(), + allowLegacyPermissionRepair: true, + requireObservedLegacyProject: false, + }) + ).registry; + } catch (error: unknown) { + if (!(error instanceof AutostartRegistryTrustError)) { + throw error; + } + // Preserve ordinary registration/prune behavior for unmarked legacy data. + // Replacing a link publishes a fresh file at the registry path and leaves + // its target untouched; none of the copied paths gains startup authority. + return { + ...observed, + projects: observed.projects.map((project) => ({ + ...project, + autostartRequiresReview: true, + })), + }; + } } type RegistrationUpsertOptions = { @@ -1724,9 +1743,7 @@ async function writeRegistryAtomic( )}\n`; if ( (registry.autostartGlobal || - registry.projects.some( - (project) => project.autostart || project.autostartRequiresReview - )) && + registry.projects.some((project) => project.autostart)) && Buffer.byteLength(text) > AUTOSTART_REGISTRY_LIMIT_BYTES ) { throw new AutostartRegistryTrustError(); diff --git a/tests/autostart-down-target.test.ts b/tests/autostart-down-target.test.ts new file mode 100644 index 000000000..224004fb9 --- /dev/null +++ b/tests/autostart-down-target.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { resolveFinalAutostartDownTarget } from "../src/commands/project-autostart-intent.ts"; +import { withAutostartProjectLock } from "../src/lib/autostart-project-lock.ts"; +import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; +import { restoreEnv } from "./helpers/env.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +let root = ""; +let primary = ""; +let worktree = ""; +let originalHome: string | undefined; +let originalGitConfig: string | undefined; +let originalGitSystem: string | undefined; +let env: Record; +async function git(args: string[]) { + const binary = Bun.which("git"); + if (!binary) { + throw new Error("git is required for the renamed-worktree regression"); + } + const child = Bun.spawn([binary, ...args], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code, stderr).toBe(0); + return stdout.trim(); +} +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-down-target-")); + primary = join(root, "primary"); + worktree = join(root, "worktree"); + await mkdir(join(primary, ".hack"), { recursive: true }); + await writeFile( + join(primary, ".hack", "hack.config.json"), + '{"name":"example"}' + ); + await writeFile( + join(primary, ".hack", "docker-compose.yml"), + "name: example\nservices:\n app:\n image: example.invalid/app\n" + ); + env = privateCliEnv({ + root, + env: { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" }, + }); + originalHome = process.env.HOME; + originalGitConfig = process.env.GIT_CONFIG_GLOBAL; + originalGitSystem = process.env.GIT_CONFIG_NOSYSTEM; + process.env.HOME = env.HOME; + process.env.GIT_CONFIG_GLOBAL = "/dev/null"; + process.env.GIT_CONFIG_NOSYSTEM = "1"; + await git(["init", "--initial-branch=main", primary]); + await git(["-C", primary, "add", ".hack"]); + await git([ + "-C", + primary, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "fixture", + ]); + await git(["-C", primary, "worktree", "add", "-b", "old-branch", worktree]); + await git(["-C", worktree, "branch", "-m", "new-branch"]); +}); +afterEach(async () => { + restoreEnv("HOME", originalHome); + restoreEnv("GIT_CONFIG_GLOBAL", originalGitConfig); + restoreEnv("GIT_CONFIG_NOSYSTEM", originalGitSystem); + await rm(root, { recursive: true, force: true }); +}); +function runtime(branch: string | null): RuntimeProject { + return { + project: branch === null ? "example" : `example--${branch}`, + workingDir: join(worktree, ".hack"), + services: new Map(), + isGlobal: false, + }; +} +function dependencies(projects: readonly RuntimeProject[]) { + return { + readProjectsRegistry: async () => ({ version: 1 as const, projects: [] }), + readRuntimeProjects: async () => ({ + ok: true as const, + runtime: projects, + error: null, + checkedAtMs: 1, + }), + isLocalTarget: async () => true, + }; +} + +test("renamed linked worktree resolves its old owned branch before autostart pause and lock selection", async () => { + expect(await git(["-C", worktree, "branch", "--show-current"])).toBe( + "new-branch" + ); + const notices: string[] = []; + const target = await resolveFinalAutostartDownTarget( + { cwd: worktree, writeNotice: ({ message }) => notices.push(message) }, + dependencies([runtime("old-branch")]) + ); + expect(target).toEqual({ branch: "old-branch" }); + expect(notices).toHaveLength(2); + expect(notices[0]).toContain("Linked worktree detected"); + expect(notices[1]).toContain("Targeting the owned runtime"); +}); + +test("owned base runtime remains an explicit null target after a linked branch rename", async () => { + const target = await resolveFinalAutostartDownTarget( + { cwd: worktree }, + dependencies([runtime(null)]) + ); + expect(target).toEqual({ branch: null }); +}); + +test("ambiguous renamed-worktree runtimes refuse instead of guessing a pause or lock target", async () => { + await expect( + resolveFinalAutostartDownTarget( + { cwd: worktree }, + dependencies([runtime("old-branch"), runtime("another")]) + ) + ).rejects.toThrow("Refusing an ambiguous implicit down"); +}); + +test("explicit branch and primary base never inspect Docker runtime", async () => { + const deps = { + ...dependencies([]), + readRuntimeProjects: async (): Promise => { + throw new Error("runtime must not be inspected"); + }, + }; + expect( + await resolveFinalAutostartDownTarget( + { cwd: worktree, branch: "chosen" }, + deps + ) + ).toEqual({ branch: "chosen" }); + expect(await resolveFinalAutostartDownTarget({ cwd: primary }, deps)).toEqual( + { branch: null } + ); +}); + +test("remote defaults and native inputs retain their original dispatch without local retargeting", async () => { + const deps = { + ...dependencies([]), + isLocalTarget: async () => false, + readRuntimeProjects: async (): Promise => { + throw new Error("remote runtime must not be inspected"); + }, + }; + expect( + await resolveFinalAutostartDownTarget({ cwd: worktree }, deps) + ).toBeUndefined(); + await writeFile( + join(worktree, ".hack", "hack.project.json"), + '{"schema_version":1,"name":"example","services":{}}' + ); + expect( + await resolveFinalAutostartDownTarget({ cwd: worktree }, dependencies([])) + ).toBeUndefined(); +}); + +test("unavailable runtime refuses before a final target is published", async () => { + await expect( + resolveFinalAutostartDownTarget( + { cwd: worktree }, + { + ...dependencies([]), + readRuntimeProjects: async () => ({ + ok: false, + runtime: [], + error: "fixture unavailable", + checkedAtMs: 1, + }), + } + ) + ).rejects.toMatchObject({ code: "E_DOCKER_UNAVAILABLE" }); +}); + +for (const ownedBranch of ["old-branch", null]) { + test(`source down pauses and locks the owned ${ownedBranch ?? "base"} runtime after a real worktree branch rename`, async () => { + const ownedComposeProject = + ownedBranch === null ? "example" : `example--${ownedBranch}`; + const home = env.HOME; + if (!home) { + throw new Error("fixture HOME is required"); + } + const registryDir = join(home, ".hack"); + await mkdir(registryDir, { recursive: true }); + const registryPath = join(registryDir, "projects.json"); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [ + { + id: "example-id", + name: "example", + repoRoot: primary, + projectDir: join(primary, ".hack"), + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + worktrees: [ + { + path: worktree, + branch: "new-branch", + lastSeenAt: "2026-01-01T00:00:00Z", + }, + ], + autostart: { + branch: ownedBranch, + paused: false, + lastResult: null, + lastAttemptBootId: null, + }, + }, + ], + }), + { mode: 0o600 } + ); + const bin = join(root, "bin"); + await mkdir(bin); + const calls = join(root, "docker-calls"); + await writeFile( + join(bin, "docker"), + `#!${process.execPath}\nconst args=process.argv.slice(2); if(args.includes("down")) { const f=Bun.file(${JSON.stringify(calls)}); await Bun.write(f, (await f.exists() ? await f.text() : "")+JSON.stringify(args)+"\\n"); } else if(args.includes("ps")||args.includes("inspect")) process.stdout.write("[]");\n`, + { mode: 0o755 } + ); + const gitPath = Bun.which("git"); + if (!gitPath) { + throw new Error("fixture git required"); + } + const { symlink } = await import("node:fs/promises"); + await symlink(gitPath, join(bin, "git")); + const driver = join(root, "down-driver.ts"); + const runtimeModule = resolve("src/lib/runtime-projects.ts"); + const cliModule = resolve("src/cli/run.ts"); + await writeFile( + driver, + ` +import { mock } from "bun:test"; +const actual = await import(${JSON.stringify(runtimeModule)}); +const replacement = { ...actual, readRuntimeProjects: async () => ({ + ok: true, error: null, checkedAtMs: 1, + runtime: [{project: ${JSON.stringify(ownedComposeProject)}, workingDir: ${JSON.stringify(join(worktree, ".hack"))}, services: new Map(), isGlobal: false}] +}) }; +mock.module(${JSON.stringify(runtimeModule)}, () => replacement); +const { runCli } = await import(${JSON.stringify(cliModule)}); +process.exitCode = await runCli(["down", "--json", "--no-interactive"]); +` + ); + + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const lock = withAutostartProjectLock({ + registryPath, + scope: { + projectId: "example-id", + composeProject: "example", + branch: ownedBranch, + }, + run: async () => { + entered.resolve(); + await release.promise; + }, + }); + await entered.promise; + const child = Bun.spawn([process.execPath, driver], { + cwd: worktree, + env: { ...env, PATH: bin }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const output = Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + try { + const deadline = Date.now() + 8000; + while ( + Date.now() < deadline && + child.exitCode === null && + !(await Bun.file(registryPath).json()).projects[0].autostart.paused + ) { + await Bun.sleep(10); + } + if ( + !(await Bun.file(registryPath).json()).projects[0].autostart.paused && + child.exitCode !== null + ) { + const [stdout, stderr, code] = await output; + throw new Error( + `source down exited before pause (code ${code}): ${stdout}${stderr}` + ); + } + expect( + (await Bun.file(registryPath).json()).projects[0].autostart.paused + ).toBe(true); + expect(child.exitCode).toBeNull(); + expect(await Bun.file(calls).exists()).toBe(false); + release.resolve(); + await lock; + const [stdout, stderr, code] = await output; + expect(code, stdout + stderr).toBe(0); + expect(JSON.parse(stdout)).toMatchObject({ + ok: true, + data: { branch: ownedBranch }, + }); + const downArgs = JSON.parse((await Bun.file(calls).text()).trim()); + if (ownedBranch === null) { + expect(downArgs).not.toContain("--project-name"); + expect(downArgs).not.toContain("-p"); + } else { + expect(downArgs).toContain(ownedComposeProject); + } + expect(downArgs).not.toContain("example--new-branch"); + } finally { + release.resolve(); + await lock; + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + await output; + } + }, 15_000); +} diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md index cff0360ff..4a34b9244 100644 --- a/tests/models/tla/autostart-start-stop/COVERAGE.md +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -14,7 +14,7 @@ separate transitions; they are not collapsed into one atomic command. | Model action | Source boundary | Regression mapping | | --- | --- | --- | -| `DownPause` | `handleDown` publishes pause through `updateAutostartIntent` before waiting for the project lock. | `tests/autostart-lifecycle-intent.test.ts`: source down pauses before waiting for a start and holds admission through runtime shutdown. | +| `DownPause` | `handleDown` first pins the reconciled runtime branch, then publishes pause through `updateAutostartIntent` before waiting for that instance lock. | `tests/autostart-lifecycle-intent.test.ts`: source down pauses before waiting for a start and holds admission through runtime shutdown. | | `LoginLock` / `LoginAdmit` / `LoginRefuse` | `withAutostartCommandLock` and `withAutostartProjectLock` serialize the actual CLI child; `assertAutostartProjectCurrent` rereads trusted intent while admission is held. | The same source-down regression rejects the earlier start after pause and a queued later start after successful down. | | `ChildStart` / `ChildSettle` | Runtime startup effects execute inside the login child's project lock; the owning command settles before lock release. A child admitted just before pause may finish before down obtains the lock. | `tests/autostart-project-lock.test.ts`: start/stop admission serializes the Compose instance even after registry identity changes. | | `DownLock` / `DownStop` / `DownReturn` | Foreground down holds the same instance lock through runtime shutdown and successful return. | The source-down regression blocks a queued start while fake shutdown is still running. | diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index 80a6ff38c..bf0cc22ed 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -1063,6 +1063,82 @@ test("ordinary legacy republication records review provenance before later named expect((await read("beta"))?.autostartRequiresReview).toBe(true); }); +test("ordinary registration replaces an unmarked registry symlink without changing its target", async () => { + const project = await createTrustProject(); + const target = join(root, "linked-legacy-registry.json"); + await rename(registryPath, target); + await symlink(target, registryPath); + const targetBefore = await readFile(target, "utf8"); + await expect( + setProjectAutostart({ + id: "alpha", + registryPath, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect((await upsertProjectRegistration({ project })).status).toBe("updated"); + expect(await readFile(target, "utf8")).toBe(targetBefore); + expect((await stat(registryPath)).mode & 0o777).toBe(0o600); + expect((await stat(registryPath)).ino).not.toBe((await stat(target)).ino); + expect((await read())?.autostartRequiresReview).toBe(true); + expect((await read("beta"))?.autostartRequiresReview).toBe(true); +}); + +test("ordinary prune can replace an unmarked registry symlink while retaining review flags", async () => { + const target = join(root, "prune-linked-registry.json"); + await rename(registryPath, target); + await symlink(target, registryPath); + const targetBefore = await readFile(target, "utf8"); + expect( + (await removeProjectsById({ ids: ["beta"] })).removed.map( + (project) => project.id + ) + ).toEqual(["beta"]); + expect(await readFile(target, "utf8")).toBe(targetBefore); + expect((await stat(registryPath)).mode & 0o777).toBe(0o600); + expect( + (await readProjectsRegistry({ registryPath })).projects.map( + (project) => project.id + ) + ).toEqual(["alpha"]); + expect((await read())?.autostartRequiresReview).toBe(true); +}); + +test("ordinary oversized legacy registration and prune preserve data without granting intent", async () => { + const project = await createTrustProject(); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [ + entry("alpha"), + { ...entry("beta"), devHost: "x".repeat(16 * 1024 * 1024) }, + ], + }) + ); + expect((await stat(registryPath)).size).toBeGreaterThan(16 * 1024 * 1024); + expect((await upsertProjectRegistration({ project })).status).toBe("updated"); + expect((await stat(registryPath)).size).toBeGreaterThan(16 * 1024 * 1024); + expect((await read("beta"))?.devHost?.length).toBe(16 * 1024 * 1024); + expect((await read("beta"))?.autostartRequiresReview).toBe(true); + await expect( + setProjectAutostart({ + id: "alpha", + registryPath, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await removeProjectsById({ ids: ["beta"] }); + expect((await stat(registryPath)).size).toBeLessThan(16 * 1024 * 1024); + expect((await read())?.autostartRequiresReview).toBe(true); +}); + test("an existing marker cannot bypass retained checkout-review provenance", async () => { const project = await createTrustProject(); await setProjectAutostart({ id: "alpha", registryPath }); From debfdb9935eca02a5f0aa3d4150a37082fdc2b28 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:00:34 +0000 Subject: [PATCH 11/44] fix(registry): retain lenient unmarked record updates --- docs/always-on-projects.md | 3 ++- src/lib/projects-registry.ts | 24 +++++++++++++++------ tests/project-autostart.test.ts | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 4ec4f2a0d..e65f4a968 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -97,7 +97,8 @@ until they are opted in from their own checkout. Trusted named targets continue to work from other directories. The old inode is never reused, so an already-open writer cannot change the replacement. This migration refuses symlinks, foreign ownership, malformed registries and preexisting intent in an unsafe file. Ordinary registration and -prune operations keep their prior behavior for an unmarked registry, including +prune operations keep their lenient handling of malformed unmarked records and +worktree metadata, including replacing a registry symlink itself with a regular file while leaving its target unchanged. They retain review requirements on copied paths; this does not authorize autostart. That check repeats at every autostart read and mutation. Unsafe registry diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 4f1b8187b..2266fcc7d 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -207,6 +207,17 @@ export class AutostartRegistryTrustError extends HackCliError { } } +/** Strict intent mutations cannot discard malformed registration or worktree records. */ +class AutostartRegistryInvalidError extends HackCliError { + constructor() { + super({ + code: "E_CONFIG_INVALID", + message: + "Projects registry is invalid; autostart intent was not changed.", + }); + } +} + /** * Trust the opened file, never a pre-open path observation. The UID check applies * on hosts with a user identity; Unix file modes must deny group/other writes. @@ -417,7 +428,12 @@ async function readProjectsRegistryForMutation(): Promise { }) ).registry; } catch (error: unknown) { - if (!(error instanceof AutostartRegistryTrustError)) { + if ( + !( + error instanceof AutostartRegistryTrustError || + error instanceof AutostartRegistryInvalidError + ) + ) { throw error; } // Preserve ordinary registration/prune behavior for unmarked legacy data. @@ -1013,11 +1029,7 @@ async function readTrustedAutostartRegistry( requireAutostartTrust: true, }); if (read.kind === "invalid" || (read.kind === "valid" && read.rejected > 0)) { - throw new HackCliError({ - code: "E_CONFIG_INVALID", - message: - "Projects registry is invalid; autostart intent was not changed.", - }); + throw new AutostartRegistryInvalidError(); } return read.kind === "valid" ? read.registry diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index bf0cc22ed..ff3bee67a 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -1063,6 +1063,44 @@ test("ordinary legacy republication records review provenance before later named expect((await read("beta"))?.autostartRequiresReview).toBe(true); }); +for (const operation of ["register", "prune"] as const) { + test(`ordinary ${operation} tolerates malformed unmarked worktree metadata without granting intent`, async () => { + const project = await createTrustProject(); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [ + entry("alpha"), + { + ...entry("beta"), + worktrees: [{ path: join(root, "incomplete"), branch: "preview" }], + }, + ], + }) + ); + const before = await readFile(registryPath, "utf8"); + await expect( + setProjectAutostart({ id: "alpha", registryPath }) + ).rejects.toMatchObject({ code: "E_CONFIG_INVALID" }); + expect(await readFile(registryPath, "utf8")).toBe(before); + if (operation === "register") { + expect((await upsertProjectRegistration({ project })).status).toBe( + "updated" + ); + expect((await read("beta"))?.worktrees).toBeUndefined(); + expect((await read("beta"))?.autostartRequiresReview).toBe(true); + } else { + expect( + (await removeProjectsById({ ids: ["beta"] })).removed + ).toHaveLength(1); + expect(await read("beta")).toBeUndefined(); + } + expect((await read())?.autostart).toBeUndefined(); + expect((await read())?.autostartRequiresReview).toBe(true); + }); +} + test("ordinary registration replaces an unmarked registry symlink without changing its target", async () => { const project = await createTrustProject(); const target = join(root, "linked-legacy-registry.json"); From 6e4dcc78468fc74d392b28ed6b65bca9566349ef Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:29:45 +0000 Subject: [PATCH 12/44] fix(autostart): authenticate installed routing assets before login execution --- docs/always-on-projects.md | 14 + src/commands/global-autostart.ts | 67 +++++ src/commands/global.ts | 47 +--- src/daemon/autostart.ts | 4 + src/lib/autostart-routing-assets.ts | 304 ++++++++++++++++++++++ tests/autostart-routing-assets.test.ts | 338 +++++++++++++++++++++++++ tests/daemon-autostart.test.ts | 17 ++ 7 files changed, 757 insertions(+), 34 deletions(-) create mode 100644 src/commands/global-autostart.ts create mode 100644 src/lib/autostart-routing-assets.ts create mode 100644 tests/autostart-routing-assets.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index e65f4a968..52a8bbb3d 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -71,6 +71,20 @@ under the owned home (plus Linux `.local/bin`); launching-shell `DOCKER_*`, `COM Hack adds only its explicit internal noninteractive and identity settings. Already-running instances are adopted without repeating startup hooks. An unknown runtime state is skipped rather than taken as proof that the instance is down. + +Automatic routing accepts only the already-installed files produced by this +CLI's current Caddy, CoreDNS, logging, Grafana and schema templates. It checks +ownership, file type and every directory component, refuses symlinks and unsafe +parent directories, and compares bounded reads with those templates. All files +must pass before matching bytes are republished on fresh `0600` inodes. Routing +and logging containers are recreated to bind those new files; an old open writer +or old file mount cannot modify the new assets. Compose `.env` imports and the +unsupported remote-route bridge are disabled for this automatic path. +Customized, outdated, missing or unsafe assets cause `routing_failed` and no +project starts. This path does not install or migrate assets. Review the paths, +preserve suspect files outside the active installation, secure their parent +directories, and regenerate the installed files with `hack global install` +before another login pass. Ordinary manual routing commands keep their behavior. Adoption requires a running container. An exited-zero container is treated as a completed job only when its service is a dependency target explicitly declared with `condition: service_completed_successfully`; other stopped services require diff --git a/src/commands/global-autostart.ts b/src/commands/global-autostart.ts new file mode 100644 index 000000000..41fa71a76 --- /dev/null +++ b/src/commands/global-autostart.ts @@ -0,0 +1,67 @@ +import { secureAutostartRoutingAssets } from "../lib/autostart-routing-assets.ts"; +import { HackCliError } from "../lib/cli-result.ts"; +import { resolveDefaultDomain } from "../lib/default-domain.ts"; +import { resolveProxyBindAddress } from "../lib/proxy-bind.ts"; +import { reconcileRemoteCaddyRoutesStack } from "../lib/remote-caddy-routes.ts"; +import { logger } from "../ui/logger.ts"; + +/** No engine or host effect precedes authentication of automatically executed assets. */ +export async function secureGlobalLoginRouting(opts: { + readonly root: string; + readonly login: boolean; +}): Promise { + if (!opts.login) { + return; + } + try { + await secureAutostartRoutingAssets({ + root: opts.root, + bindAddress: await resolveProxyBindAddress(), + defaultDomain: await resolveDefaultDomain(), + }); + } catch { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Automatic routing refused untrusted or outdated installed assets. Review the routing paths and reinstall current assets before retrying. Values omitted.", + detail: { reason: "routing_assets_untrusted" }, + }); + } +} + +/** Recreate login containers so file mounts bind the newly published private inodes. */ +export function globalRoutingUpArguments(opts: { + readonly composeFile: string; + readonly login: boolean; +}): readonly string[] { + return [ + "docker", + "compose", + ...(opts.login ? ["--env-file", "/dev/null"] : []), + "-f", + opts.composeFile, + "up", + "-d", + ...(opts.login ? ["--force-recreate"] : []), + "--remove-orphans", + ]; +} + +/** Unsupported remote bridges are never part of automatic local routing startup. */ +export async function reconcileGlobalRoutingBridge(opts: { + readonly enabled: boolean; +}): Promise { + if (!opts.enabled) { + return; + } + const remoteRoutes = await reconcileRemoteCaddyRoutesStack(); + if (remoteRoutes.status === "failed") { + logger.warn({ + message: `Remote route bridge sync failed: ${remoteRoutes.error}`, + }); + } else if (remoteRoutes.status === "applied") { + logger.step({ + message: `Remote route bridge active (${remoteRoutes.routeCount} host${remoteRoutes.routeCount === 1 ? "" : "s"})`, + }); + } +} diff --git a/src/commands/global.ts b/src/commands/global.ts index 321e6605d..998fb2f44 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -137,10 +137,7 @@ import { resolveProxyBindAddress, } from "../lib/proxy-bind.ts"; import { diagnoseProxyPortConflict } from "../lib/proxy-port-listeners.ts"; -import { - reconcileRemoteCaddyRoutesStack, - stopRemoteCaddyRoutesStack, -} from "../lib/remote-caddy-routes.ts"; +import { stopRemoteCaddyRoutesStack } from "../lib/remote-caddy-routes.ts"; import { detectDockerBackend, formatDockerConnectionGuidance, @@ -166,6 +163,11 @@ import { dockerComposeLogsPretty } from "../ui/docker-logs.ts"; import { ensureBundledGumInstalled } from "../ui/gum.ts"; import { logger } from "../ui/logger.ts"; import { resolvePreferredHostDnsTarget } from "./doctor-utils.ts"; +import { + globalRoutingUpArguments, + reconcileGlobalRoutingBridge, + secureGlobalLoginRouting, +} from "./global-autostart.ts"; import { bootstrapLinuxGlobalDns, inspectLinuxGlobalDns, @@ -1820,12 +1822,14 @@ export async function globalUp(): Promise { return slimExit; } + const paths = getGlobalPaths(); + const login = process.env.HACK_AUTOSTART_LOGIN === "1"; + await secureGlobalLoginRouting({ root: paths.root, login }); + await ensureDockerRunning(); if (isMac()) { await ensureMacDnsmasqRunning(); } - const paths = getGlobalPaths(); - if (!(await pathExists(paths.caddyCompose))) { logger.error({ message: `Missing ${paths.caddyCompose}. Run: hack global install`, @@ -1891,15 +1895,7 @@ export async function globalUp(): Promise { logger.step({ message: "Starting Caddy…" }); const caddyExit = await run( - [ - "docker", - "compose", - "-f", - paths.caddyCompose, - "up", - "-d", - "--remove-orphans", - ], + globalRoutingUpArguments({ composeFile: paths.caddyCompose, login }), { cwd: dirname(paths.caddyCompose) } ); if (caddyExit !== 0) { @@ -1926,28 +1922,11 @@ export async function globalUp(): Promise { }); return 1; } - const remoteRoutes = await reconcileRemoteCaddyRoutesStack(); - if (remoteRoutes.status === "failed") { - logger.warn({ - message: `Remote route bridge sync failed: ${remoteRoutes.error}`, - }); - } else if (remoteRoutes.status === "applied") { - logger.step({ - message: `Remote route bridge active (${remoteRoutes.routeCount} host${remoteRoutes.routeCount === 1 ? "" : "s"})`, - }); - } + await reconcileGlobalRoutingBridge({ enabled: !login }); logger.step({ message: "Starting logging stack…" }); const logExit = await run( - [ - "docker", - "compose", - "-f", - paths.loggingCompose, - "up", - "-d", - "--remove-orphans", - ], + globalRoutingUpArguments({ composeFile: paths.loggingCompose, login }), { cwd: dirname(paths.loggingCompose) } ); if (logExit !== 0) { diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index fa806bd76..aae5050b8 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -549,6 +549,7 @@ function productionDependencies( timeoutMs: 60_000, signal, env, + routing: true, } ) ).exitCode === 0, @@ -943,6 +944,8 @@ export async function runAutostartSubprocess( readonly signal?: AbortSignal; readonly env?: Readonly>; readonly projectId?: string; + /** Routing helpers must not load a working-directory Compose .env. */ + readonly routing?: boolean; readonly terminationGraceMs?: number; } ): Promise<{ @@ -961,6 +964,7 @@ export async function runAutostartSubprocess( HACK_DAEMON_AUTOSTART_DISABLE: "1", HACK_AUTOSTART_LOGIN: "1", ...(opts.projectId ? { HACK_AUTOSTART_PROJECT_ID: opts.projectId } : {}), + ...(opts.routing ? { COMPOSE_ENV_FILES: "/dev/null" } : {}), }, stdin: "ignore", stdout: "pipe", diff --git a/src/lib/autostart-routing-assets.ts b/src/lib/autostart-routing-assets.ts new file mode 100644 index 000000000..5aa7a49e6 --- /dev/null +++ b/src/lib/autostart-routing-assets.ts @@ -0,0 +1,304 @@ +import { Buffer } from "node:buffer"; +import { randomUUID } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { lstat, open, readdir, rename, unlink } from "node:fs/promises"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { + GLOBAL_ALLOY_FILENAME, + GLOBAL_BRANCHES_SCHEMA_FILENAME, + GLOBAL_CADDY_COMPOSE_FILENAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CONFIG_SCHEMA_FILENAME, + GLOBAL_COREDNS_FILENAME, + GLOBAL_ENV_SCHEMA_FILENAME, + GLOBAL_GRAFANA_DASHBOARD_FILENAME, + GLOBAL_GRAFANA_DASHBOARDS_DIR, + GLOBAL_GRAFANA_DASHBOARDS_PROVISIONING_FILENAME, + GLOBAL_GRAFANA_DATASOURCE_FILENAME, + GLOBAL_LOGGING_COMPOSE_FILENAME, + GLOBAL_LOGGING_DIR_NAME, + GLOBAL_LOKI_CONFIG_FILENAME, + GLOBAL_MANAGED_ENV_SCHEMA_FILENAME, + GLOBAL_SCHEMAS_DIR_NAME, +} from "../constants.ts"; +import { + renderGlobalAlloyConfig, + renderGlobalCaddyCompose, + renderGlobalCoreDnsConfig, + renderGlobalGrafanaDashboardsProvisioningYaml, + renderGlobalGrafanaDatasourceYaml, + renderGlobalGrafanaLogsDashboardJson, + renderGlobalLoggingCompose, + renderGlobalLokiConfigYaml, + renderProjectBranchesSchemaJson, + renderProjectConfigSchemaJson, + renderProjectEnvSchemaJson, + renderProjectManagedEnvSchemaJson, +} from "../templates.ts"; +import { HackCliError } from "./cli-result.ts"; +import { parseDefaultDomain } from "./default-domain.ts"; +import { checkReplaceableChain } from "./path-trust.ts"; +import { parseProxyBindAddress } from "./proxy-bind.ts"; + +type RoutingAsset = { + readonly path: string; + readonly accepted: readonly string[]; +}; +type ObservedAsset = { + readonly path: string; + readonly text: string; + readonly stat: Stats; +}; +export type AutostartRoutingAssetsOptions = { + readonly root: string; + readonly bindAddress: string | null; + readonly defaultDomain: string; +}; +const MAX_ASSET_BYTES = 1024 * 1024; + +/** + * Login executes only already-installed current templates. Authentication is + * read-only; publication replaces matching bytes on private fresh inodes so an + * older writable descriptor cannot change what the upcoming Compose run reads. + * Missing/custom assets refuse; this is neither installation nor migration. + */ +export async function secureAutostartRoutingAssets( + opts: AutostartRoutingAssetsOptions +): Promise { + try { + if (!isAbsolute(opts.root) || process.getuid === undefined) { + throw new Error("Unavailable owner"); + } + const uid = process.getuid(); + const assets = routingAssets(opts); + for (const asset of assets) { + await assertSafeAssetParent({ root: opts.root, path: asset.path, uid }); + } + await assertMountedDirectory({ + path: resolve(opts.root, GLOBAL_SCHEMAS_DIR_NAME), + names: [ + GLOBAL_CONFIG_SCHEMA_FILENAME, + GLOBAL_ENV_SCHEMA_FILENAME, + GLOBAL_MANAGED_ENV_SCHEMA_FILENAME, + GLOBAL_BRANCHES_SCHEMA_FILENAME, + ], + }); + await assertMountedDirectory({ + path: resolve( + opts.root, + GLOBAL_LOGGING_DIR_NAME, + GLOBAL_GRAFANA_DASHBOARDS_DIR + ), + names: [basename(GLOBAL_GRAFANA_DASHBOARD_FILENAME)], + }); + const observed: ObservedAsset[] = []; + for (const asset of assets) { + observed.push(await readCurrentAsset({ asset, uid })); + } + for (const asset of observed) { + await publishFreshAsset({ asset, root: opts.root, uid }); + } + } catch { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Automatic routing startup refused untrusted or outdated installed assets. Run hack global install manually, then retry. Values omitted.", + detail: { reason: "routing_assets_untrusted" }, + }); + } +} + +function routingAssets( + opts: AutostartRoutingAssetsOptions +): readonly RoutingAsset[] { + const bindAddress = + opts.bindAddress === null ? null : parseProxyBindAddress(opts.bindAddress); + const defaultDomain = parseDefaultDomain(opts.defaultDomain); + const caddy = resolve(opts.root, GLOBAL_CADDY_DIR_NAME); + const logging = resolve(opts.root, GLOBAL_LOGGING_DIR_NAME); + const schemas = resolve(opts.root, GLOBAL_SCHEMAS_DIR_NAME); + const compose: string[] = []; + for (const useStaticCoreDnsIp of [false, true]) { + for (const useStaticCaddyIp of [false, true]) { + compose.push( + renderGlobalCaddyCompose({ + useStaticCoreDnsIp, + useStaticCaddyIp, + bindAddress, + }) + ); + } + } + return [ + { path: resolve(caddy, GLOBAL_CADDY_COMPOSE_FILENAME), accepted: compose }, + { + path: resolve(caddy, GLOBAL_COREDNS_FILENAME), + accepted: [false, true].map((useStaticCaddyIp) => + renderGlobalCoreDnsConfig({ useStaticCaddyIp, defaultDomain }) + ), + }, + ...( + [ + [GLOBAL_LOGGING_COMPOSE_FILENAME, renderGlobalLoggingCompose()], + [GLOBAL_ALLOY_FILENAME, renderGlobalAlloyConfig()], + [GLOBAL_LOKI_CONFIG_FILENAME, renderGlobalLokiConfigYaml()], + [ + GLOBAL_GRAFANA_DATASOURCE_FILENAME, + renderGlobalGrafanaDatasourceYaml(), + ], + [ + GLOBAL_GRAFANA_DASHBOARDS_PROVISIONING_FILENAME, + renderGlobalGrafanaDashboardsProvisioningYaml(), + ], + [ + GLOBAL_GRAFANA_DASHBOARD_FILENAME, + renderGlobalGrafanaLogsDashboardJson(), + ], + ] as const + ).map(([name, text]) => ({ + path: resolve(logging, name), + accepted: [text], + })), + ...( + [ + [GLOBAL_CONFIG_SCHEMA_FILENAME, renderProjectConfigSchemaJson()], + [GLOBAL_ENV_SCHEMA_FILENAME, renderProjectEnvSchemaJson()], + [ + GLOBAL_MANAGED_ENV_SCHEMA_FILENAME, + renderProjectManagedEnvSchemaJson(), + ], + [GLOBAL_BRANCHES_SCHEMA_FILENAME, renderProjectBranchesSchemaJson()], + ] as const + ).map(([name, text]) => ({ + path: resolve(schemas, name), + accepted: [text], + })), + ]; +} + +async function assertSafeAssetParent(opts: { + readonly root: string; + readonly path: string; + readonly uid: number; +}): Promise { + const parent = dirname(opts.path); + if ( + checkReplaceableChain({ path: parent, uid: opts.uid, hackHome: null }) !== + null + ) { + throw new Error("Unsafe directory chain"); + } + let current = resolve(opts.root); + for (const component of [ + "", + ...relative(current, parent).split("/").filter(Boolean), + ]) { + current = resolve(current, component); + const info = await lstat(current); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error("Unsupported directory"); + } + } +} + +async function assertMountedDirectory(opts: { + readonly path: string; + readonly names: readonly string[]; +}): Promise { + const entries = (await readdir(opts.path)).sort(); + const names = [...opts.names].sort(); + if ( + entries.length !== names.length || + entries.some((entry, i) => entry !== names[i]) + ) { + throw new Error("Unknown mounted content"); + } +} + +async function readCurrentAsset(opts: { + readonly asset: RoutingAsset; + readonly uid: number; +}): Promise { + const file = await open( + opts.asset.path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const before = await file.stat(); + if ( + !before.isFile() || + before.uid !== opts.uid || + before.size > MAX_ASSET_BYTES || + before.size === 0 + ) { + throw new Error("Unsupported file"); + } + const bytes = Buffer.alloc(before.size + 1); + let count = 0; + while (count < bytes.length) { + const read = await file.read(bytes, count, bytes.length - count, count); + if (read.bytesRead === 0) { + break; + } + count += read.bytesRead; + } + const after = await file.stat(); + const text = new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(0, count) + ); + if ( + count !== before.size || + !sameObservedFile(before, after) || + !opts.asset.accepted.includes(text) + ) { + throw new Error("Asset mismatch"); + } + return { path: opts.asset.path, text, stat: after }; + } finally { + await file.close(); + } +} + +function sameObservedFile(a: Stats, b: Stats): boolean { + return ( + a.dev === b.dev && + a.ino === b.ino && + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.uid === b.uid + ); +} + +async function publishFreshAsset(opts: { + readonly asset: ObservedAsset; + readonly root: string; + readonly uid: number; +}): Promise { + const { asset } = opts; + await assertSafeAssetParent({ ...opts, path: asset.path }); + const current = await lstat(asset.path); + if (!(current.isFile() && sameObservedFile(asset.stat, current))) { + throw new Error("Asset changed"); + } + const staging = resolve( + dirname(asset.path), + `.autostart-routing-${randomUUID()}` + ); + const file = await open(staging, "wx", 0o600); + try { + await file.writeFile(asset.text, "utf8"); + await file.chmod(0o600); + await file.sync(); + await assertSafeAssetParent({ ...opts, path: asset.path }); + if (!sameObservedFile(current, await lstat(asset.path))) { + throw new Error("Asset changed"); + } + await rename(staging, asset.path); + } finally { + await file.close(); + await unlink(staging).catch(() => { + /* A successful publication moved this owned staging file. */ + }); + } +} diff --git a/tests/autostart-routing-assets.test.ts b/tests/autostart-routing-assets.test.ts new file mode 100644 index 000000000..698a3b809 --- /dev/null +++ b/tests/autostart-routing-assets.test.ts @@ -0,0 +1,338 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + open, + readdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { globalRoutingUpArguments } from "../src/commands/global-autostart.ts"; +import { + GLOBAL_ALLOY_FILENAME, + GLOBAL_BRANCHES_SCHEMA_FILENAME, + GLOBAL_CADDY_COMPOSE_FILENAME, + GLOBAL_CADDY_DIR_NAME, + GLOBAL_CONFIG_SCHEMA_FILENAME, + GLOBAL_COREDNS_FILENAME, + GLOBAL_ENV_SCHEMA_FILENAME, + GLOBAL_GRAFANA_DASHBOARD_FILENAME, + GLOBAL_GRAFANA_DASHBOARDS_PROVISIONING_FILENAME, + GLOBAL_GRAFANA_DATASOURCE_FILENAME, + GLOBAL_LOGGING_COMPOSE_FILENAME, + GLOBAL_LOGGING_DIR_NAME, + GLOBAL_LOKI_CONFIG_FILENAME, + GLOBAL_MANAGED_ENV_SCHEMA_FILENAME, + GLOBAL_SCHEMAS_DIR_NAME, +} from "../src/constants.ts"; +import { secureAutostartRoutingAssets } from "../src/lib/autostart-routing-assets.ts"; +import { + renderGlobalAlloyConfig, + renderGlobalCaddyCompose, + renderGlobalCoreDnsConfig, + renderGlobalGrafanaDashboardsProvisioningYaml, + renderGlobalGrafanaDatasourceYaml, + renderGlobalGrafanaLogsDashboardJson, + renderGlobalLoggingCompose, + renderGlobalLokiConfigYaml, + renderProjectBranchesSchemaJson, + renderProjectConfigSchemaJson, + renderProjectEnvSchemaJson, + renderProjectManagedEnvSchemaJson, +} from "../src/templates.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +let root = ""; +let assets: Map; +const defaultDomain = "example.test"; +const bindAddress = "127.0.0.1"; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-routing-assets-")); + const caddy = join(root, GLOBAL_CADDY_DIR_NAME); + const logging = join(root, GLOBAL_LOGGING_DIR_NAME); + const schemas = join(root, GLOBAL_SCHEMAS_DIR_NAME); + assets = new Map([ + [ + join(caddy, GLOBAL_CADDY_COMPOSE_FILENAME), + renderGlobalCaddyCompose({ bindAddress }), + ], + [ + join(caddy, GLOBAL_COREDNS_FILENAME), + renderGlobalCoreDnsConfig({ defaultDomain }), + ], + [ + join(logging, GLOBAL_LOGGING_COMPOSE_FILENAME), + renderGlobalLoggingCompose(), + ], + [join(logging, GLOBAL_ALLOY_FILENAME), renderGlobalAlloyConfig()], + [join(logging, GLOBAL_LOKI_CONFIG_FILENAME), renderGlobalLokiConfigYaml()], + [ + join(logging, GLOBAL_GRAFANA_DATASOURCE_FILENAME), + renderGlobalGrafanaDatasourceYaml(), + ], + [ + join(logging, GLOBAL_GRAFANA_DASHBOARDS_PROVISIONING_FILENAME), + renderGlobalGrafanaDashboardsProvisioningYaml(), + ], + [ + join(logging, GLOBAL_GRAFANA_DASHBOARD_FILENAME), + renderGlobalGrafanaLogsDashboardJson(), + ], + [ + join(schemas, GLOBAL_CONFIG_SCHEMA_FILENAME), + renderProjectConfigSchemaJson(), + ], + [join(schemas, GLOBAL_ENV_SCHEMA_FILENAME), renderProjectEnvSchemaJson()], + [ + join(schemas, GLOBAL_MANAGED_ENV_SCHEMA_FILENAME), + renderProjectManagedEnvSchemaJson(), + ], + [ + join(schemas, GLOBAL_BRANCHES_SCHEMA_FILENAME), + renderProjectBranchesSchemaJson(), + ], + ]); + for (const [path, text] of assets) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, text, { mode: 0o664 }); + await chmod(path, 0o664); + } +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function secure(): Promise { + await secureAutostartRoutingAssets({ root, bindAddress, defaultDomain }); +} + +function caddyCompose() { + return join(root, GLOBAL_CADDY_DIR_NAME, GLOBAL_CADDY_COMPOSE_FILENAME); +} + +test("login routing recreates mounts and ignores env files while manual routing keeps its arguments", () => { + const manual = [ + "docker", + "compose", + "-f", + "/example/compose.yml", + "up", + "-d", + "--remove-orphans", + ]; + expect( + globalRoutingUpArguments({ + composeFile: "/example/compose.yml", + login: false, + }) + ).toEqual(manual); + const login = globalRoutingUpArguments({ + composeFile: "/example/compose.yml", + login: true, + }); + expect(login).toContain("--force-recreate"); + expect(login.slice(2, 4)).toEqual(["--env-file", "/dev/null"]); +}); + +test("source global up refuses poisoned login assets before any Docker effect", async () => { + await writeFile( + caddyCompose(), + "services:\n attack:\n image: attacker.invalid/example\n" + ); + const config = join(root, "hack.config.json"); + await writeFile( + config, + JSON.stringify({ + default_domain: defaultDomain, + proxy: { bind_address: bindAddress }, + }) + ); + const bin = join(root, "bin"); + await mkdir(bin, { mode: 0o700 }); + const docker = join(bin, "docker"); + await writeFile( + docker, + '#!/bin/sh\n: > "$HACK_TEST_DOCKER_SENTINEL"\nexit 0\n', + { mode: 0o700 } + ); + const sentinel = join(root, "docker-was-called"); + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + resolve("index.ts"), + "global", + "up", + "--no-interactive", + ], + { + env: privateCliEnv({ + root, + env: { + PATH: bin, + HACK_GLOBAL_CONFIG_PATH: config, + HACK_AUTOSTART_LOGIN: "1", + HACK_TEST_DOCKER_SENTINEL: sentinel, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const timer = setTimeout(() => child.kill("SIGKILL"), 5000); + try { + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(code).not.toBe(0); + expect(stdout + stderr).toContain("Automatic routing refused"); + expect(await Bun.file(sentinel).exists()).toBe(false); + } finally { + clearTimeout(timer); + } +}); + +test("canonical installed assets are preserved byte-for-byte on fresh private inodes", async () => { + const previous = new Map(); + for (const path of assets.keys()) { + previous.set(path, (await lstat(path)).ino); + } + await secure(); + for (const [path, text] of assets) { + expect(await readFile(path, "utf8")).toBe(text); + expect((await lstat(path)).mode & 0o777).toBe(0o600); + expect((await lstat(path)).ino).not.toBe(previous.get(path)); + } +}); + +test("current renderer static address variants and IPv6 bind settings are authenticated", async () => { + for (const useStaticCoreDnsIp of [false, true]) { + for (const useStaticCaddyIp of [false, true]) { + await writeFile( + caddyCompose(), + renderGlobalCaddyCompose({ + bindAddress: "::1", + useStaticCoreDnsIp, + useStaticCaddyIp, + }) + ); + await writeFile( + join(root, GLOBAL_CADDY_DIR_NAME, GLOBAL_COREDNS_FILENAME), + renderGlobalCoreDnsConfig({ defaultDomain, useStaticCaddyIp }) + ); + await secureAutostartRoutingAssets({ + root, + bindAddress: "::1", + defaultDomain, + }); + } + } +}); + +test("poisoned Compose refuses before any matching asset is republished and errors omit values", async () => { + const untouched = join(root, GLOBAL_LOGGING_DIR_NAME, GLOBAL_ALLOY_FILENAME); + const before = (await lstat(untouched)).ino; + await writeFile( + caddyCompose(), + "services: { injected: { image: private-sentinel } }" + ); + let error: unknown; + try { + await secure(); + } catch (failure) { + error = failure; + } + expect(error).toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "routing_assets_untrusted" }, + }); + expect(String(error)).not.toContain("private-sentinel"); + expect(String(error)).not.toContain(root); + expect((await lstat(untouched)).ino).toBe(before); +}); + +test("a descriptor retained on an old writable inode cannot alter the authenticated replacement", async () => { + const path = caddyCompose(); + const original = assets.get(path); + if (original === undefined) { + throw new Error("Missing fixture asset"); + } + const old = await open(path, "r+"); + try { + await secure(); + await old.write( + Buffer.from("untrusted retained descriptor"), + 0, + "untrusted retained descriptor".length, + 0 + ); + await old.sync(); + expect(await readFile(path, "utf8")).toBe(original); + expect((await old.stat()).ino).not.toBe((await lstat(path)).ino); + } finally { + await old.close(); + } +}); + +test("file and mounted directory symlinks are refused without modifying their targets", async () => { + const path = caddyCompose(); + const target = join(root, "saved-compose"); + await writeFile(target, assets.get(path) ?? ""); + await rm(path); + await symlink(target, path); + await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(await readFile(target, "utf8")).toBe(assets.get(path) ?? ""); + await rm(path); + await writeFile(path, assets.get(path) ?? ""); + const schemas = join(root, GLOBAL_SCHEMAS_DIR_NAME); + const moved = join(root, "saved-schemas"); + await mkdir(moved, { mode: 0o700 }); + await rm(schemas, { recursive: true }); + await symlink(moved, schemas); + await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(await readdir(moved)).toEqual([]); +}); + +test("unsafe directory modes refuse read-only with no automatic chmod", async () => { + const schemas = join(root, GLOBAL_SCHEMAS_DIR_NAME); + await chmod(schemas, 0o777); + await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect((await lstat(schemas)).mode & 0o777).toBe(0o777); +}); + +test("a mismatched current owner cannot authenticate the installed assets", async () => { + const getuid = process.getuid; + if (!getuid) { + throw new Error("UID unavailable"); + } + try { + process.getuid = () => getuid() + 1; + await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); + } finally { + process.getuid = getuid; + } +}); + +test("missing assets are never created and unknown mounted content is refused", async () => { + const missing = join(root, GLOBAL_LOGGING_DIR_NAME, GLOBAL_ALLOY_FILENAME); + await rm(missing); + await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(await Bun.file(missing).exists()).toBe(false); + await writeFile(missing, assets.get(missing) ?? ""); + await writeFile(join(root, GLOBAL_SCHEMAS_DIR_NAME, "unknown.json"), "{}"); + await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); + await rm(root, { recursive: true }); + await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(await Bun.file(root).exists()).toBe(false); +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index bac5d3f78..de09f47a0 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -368,6 +368,23 @@ test("only classified safe reasons are persisted; missing keys and prompts are s }); }); +test("routing children suppress Compose env files without inheriting shell overrides", async () => { + const invocation = [ + process.execPath, + "--eval", + "process.stdout.write(process.env.COMPOSE_ENV_FILES ?? 'absent')", + ]; + for (const routing of [false, true]) { + const result = await runAutostartSubprocess(invocation, { + timeoutMs: 5000, + routing, + env: { COMPOSE_ENV_FILES: "/untrusted.env" }, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(routing ? "/dev/null" : "absent"); + } +}); + test("production runner has no input terminal or keychain and does not retain stderr", async () => { const result = await runAutostartSubprocess( [ From ee22ad4a7d1dead2e46561582045a8c7b8e36349 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:57:25 +0000 Subject: [PATCH 13/44] fix(autostart): refuse replaceable checkouts and unsafe helper paths --- docs/always-on-projects.md | 18 +- src/commands/project-autostart-intent.ts | 26 +++ src/daemon/autostart.ts | 116 +++++++++- src/daemon/server.ts | 6 + src/lib/autostart-checkout-trust.ts | 39 ++++ src/lib/autostart-executable-trust.ts | 28 +++ src/lib/autostart-project-lock.ts | 10 +- src/lib/projects-registry.ts | 11 +- tests/autostart-checkout-trust.test.ts | 260 +++++++++++++++++++++++ tests/autostart-executable-trust.test.ts | 87 ++++++++ tests/daemon-autostart.test.ts | 102 ++++++++- tests/project-autostart.test.ts | 27 +++ 12 files changed, 715 insertions(+), 15 deletions(-) create mode 100644 src/lib/autostart-checkout-trust.ts create mode 100644 src/lib/autostart-executable-trust.ts create mode 100644 tests/autostart-checkout-trust.test.ts create mode 100644 tests/autostart-executable-trust.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 52a8bbb3d..f6762d4a2 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -69,6 +69,11 @@ Children receive a fixed environment allow-list and an executable search path built from system directories and known Bun, Hack and Docker directories under the owned home (plus Linux `.local/bin`); launching-shell `DOCKER_*`, `COMPOSE_*` and `HACK_*` overrides are not copied. Hack adds only its explicit internal noninteractive and identity settings. +Every login-service launch checks its effective executable lookup path before +starting an engine watcher. Explicit passes check the derived path before helper +lookup too. Directories, executable files and symlink targets must satisfy the +same read-only ownership and permission checks used by the Linux service; unsafe +paths are refused without automatic permission repair. Already-running instances are adopted without repeating startup hooks. An unknown runtime state is skipped rather than taken as proof that the instance is down. @@ -95,6 +100,12 @@ overrides, declared `scale` or `deploy.replicas`, explicit Compose file/profile overrides and unsupported composition cannot prove adoption, so they run ordinary `up` and may repeat startup hooks. +Before project input discovery and again under the instance start lock, login +checks that the checkout and project-directory ancestor chains belong to the +current user or root and cannot be replaced by other users. The check repeats +before container startup. A shared writable parent is refused even when the +saved registry pathname still matches. Normal owner Git updates remain allowed. + No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a recorded result. Run `hack up` manually to investigate and recover. The daemon does @@ -128,7 +139,9 @@ history of arbitrary external permission changes or revoke an existing writer. A claim is saved before each attempt, once per OS boot. Restarting the daemon in the same boot does not repeat starts. A crash after the claim can leave an interrupted attempt; its outcome is unknown, so this slice requires manual -`hack up` recovery. A new boot permits a new attempt. Resume permits the next login +`hack up` recovery. A new boot permits a new attempt. An unavailable boot identity records a refusal +without replacing a prior claim, so recovering the probe cannot replay an +already-attempted project. Resume permits the next login attempt; use `hack up` to start immediately. A routing failure consumes that boot's attempt; an engine timeout does not. A start has a 120-second bound, then receives TERM and a two-second grace before @@ -227,7 +240,8 @@ additive and stable; internal boot claims are not part of this public contract. Current skip reasons are `engine_timeout`, `routing_failed`, `boot_identity_unavailable`, `native_unsupported`, `runtime_unknown`, `interactive_required`, `env_key_missing`, `keychain_unavailable`, -`pending_env_migration`, `project_unavailable`, `paused`, `target_changed`, +`pending_env_migration`, `project_unavailable`, `checkout_untrusted`, +`executable_path_untrusted`, `paused`, `target_changed`, `daemon_stopped`, `global_paused`, and `invalid_marker`. Failure reasons are `start_failed`, `start_timeout`, and `output_limit`. diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 7f6ad25c4..0487ab2dd 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -6,6 +6,7 @@ import { PROJECT_ENV_CONTRACT_FILENAME, PROJECT_ENV_FILENAME, } from "../constants.ts"; +import { assertAutostartCheckoutTrusted } from "../lib/autostart-checkout-trust.ts"; import { assertAutostartProjectCurrent, withAutostartProjectLock, @@ -277,6 +278,12 @@ export async function withAutostartCommandLock(opts: { const registry = await readProjectsRegistry( login ? { requireAutostartTrust: true } : undefined ); + if (login) { + assertLoginCheckoutBeforeDiscovery({ + projectId: process.env.HACK_AUTOSTART_PROJECT_ID, + projects: registry.projects, + }); + } const context = await discoverAutostartCommandContext({ ...opts, registry }); if (!context) { return await opts.run(); @@ -319,6 +326,25 @@ export async function withAutostartCommandLock(opts: { }); } +/** Check the trusted registry target before even reading repository-controlled inputs. */ +function assertLoginCheckoutBeforeDiscovery(opts: { + readonly projectId?: string; + readonly projects: readonly RegisteredProject[]; +}): void { + const matches = opts.projects.filter( + (project) => project.id === opts.projectId + ); + const project = matches.length === 1 ? matches[0] : undefined; + if (!project) { + throw new HackCliError({ + code: "E_CANCELLED", + message: "Autostart project path changed; no start ran.", + detail: { reason: "target_changed" }, + }); + } + assertAutostartCheckoutTrusted(project); +} + function assertLoginProjectIdentity(opts: { readonly projectId?: string; readonly projects: readonly RegisteredProject[]; diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index aae5050b8..12442618b 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -9,6 +9,8 @@ import { GLOBAL_CADDY_DIR_NAME, GLOBAL_PROJECTS_REGISTRY_FILENAME, } from "../constants.ts"; +import { assertAutostartCheckoutTrusted } from "../lib/autostart-checkout-trust.ts"; +import { assertAutostartExecutablePathTrusted } from "../lib/autostart-executable-trust.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; @@ -24,6 +26,7 @@ import { recordProjectAutostartResult, setAutostartLoginWaitingForEngine, } from "../lib/projects-registry.ts"; +import { checkServiceExecutableTrusted } from "./systemd.ts"; export type AutostartResult = { readonly status: "started" | "skipped" | "failed" | "already_running"; @@ -32,11 +35,17 @@ export type AutostartResult = { }; type StartResult = Omit; +type AutostartStartupRefusal = + | "native_unsupported" + | "executable_path_untrusted" + | null; /** Side effects are explicit so ordering, bounds and restart recovery can be tested without an engine. */ export type AutostartDependencies = { readonly bootId: () => Promise; - readonly startupRefusal?: () => "native_unsupported" | null; + readonly startupRefusal?: () => + | AutostartStartupRefusal + | Promise; readonly now: () => number; readonly sleep: (ms: number, signal?: AbortSignal) => Promise; readonly engineReady: (opts?: { @@ -48,7 +57,7 @@ export type AutostartDependencies = { readonly inspect: ( project: RegisteredProject, signal?: AbortSignal - ) => Promise<"running" | "stopped" | "unknown">; + ) => Promise<"running" | "stopped" | "unknown" | "untrusted">; readonly start: ( project: RegisteredProject, signal?: AbortSignal @@ -91,7 +100,7 @@ export async function runAutostartAtLogin( const runtime = opts.runtime; const deps = opts.dependencies ?? - productionDependencies(requireAutostartRuntime(runtime)); + createAutostartProductionDependencies(requireAutostartRuntime(runtime)); const registryPath = opts.registryPath ?? (runtime @@ -123,6 +132,7 @@ async function runAutostartLoginPass(opts: { if (registry.autostartGlobal?.paused) { return { waitingForEngine: false }; } + const startupRefusal = (await deps.startupRefusal?.()) ?? null; const machineBootId = await deps.bootId().catch(() => null); const bootId = machineBootId ?? "unavailable"; const pending = registry.projects @@ -152,6 +162,17 @@ async function runAutostartLoginPass(opts: { } let waitingForEngine = false; try { + if (machineBootId === null) { + await recordUnclaimedAutostartRefusal({ + pending, + runId: loginRun.runId, + registryPath: opts.registryPath, + deps, + reason: startupRefusal ?? "boot_identity_unavailable", + signal: opts.signal, + }); + return { waitingForEngine: false }; + } const refusal = await prepareAutostartRuntime({ deps, machineBootId, @@ -159,6 +180,7 @@ async function runAutostartLoginPass(opts: { registryPath: opts.registryPath, engineWaitMs: opts.engineWaitMs ?? opts.runtime?.engineWaitMs, runId: loginRun.runId, + startupRefusal, }); if (refusal === "engine_timeout" && !opts.signal?.aborted) { waitingForEngine = true; @@ -187,6 +209,45 @@ async function runAutostartLoginPass(opts: { return { waitingForEngine: false }; } +/** Unknown boot identity cannot replace a real claim and authorize a later replay. */ +async function recordUnclaimedAutostartRefusal(opts: { + readonly pending: readonly RegisteredProject[]; + readonly runId: string; + readonly registryPath?: string; + readonly deps: AutostartDependencies; + readonly reason: string; + readonly signal?: AbortSignal; +}): Promise { + await setAutostartLoginWaitingForEngine({ + runId: opts.runId, + waitingForEngine: false, + registryPath: opts.registryPath, + }); + for (const project of opts.pending) { + const recorded = + !opts.signal?.aborted && + (await recordProjectAutostartResult({ + id: project.id, + bootId: project.autostart?.lastAttemptBootId ?? null, + expectedBranch: project.autostart?.branch, + onlyIfWanted: true, + result: { + status: "skipped", + reason: opts.reason, + at: new Date(opts.deps.now()).toISOString(), + }, + registryPath: opts.registryPath, + })); + await recordAutostartLoginOutcome({ + runId: opts.runId, + id: project.id, + succeeded: false, + cancelled: !recorded, + registryPath: opts.registryPath, + }); + } +} + async function dispatchAutostartProjects(opts: { readonly pending: readonly RegisteredProject[]; readonly deps: AutostartDependencies; @@ -260,12 +321,13 @@ async function dispatchAutostartProjects(opts: { async function prepareAutostartRuntime(opts: { readonly deps: AutostartDependencies; readonly machineBootId: string | null; + readonly startupRefusal: AutostartStartupRefusal; readonly runId: string; readonly engineWaitMs?: number; readonly registryPath?: string; readonly signal?: AbortSignal; }): Promise { - const refusal = opts.deps.startupRefusal?.(); + const refusal = opts.startupRefusal; if (refusal) { return refusal; } @@ -356,6 +418,8 @@ async function startClaimedProject(opts: { const state = await opts.deps.inspect(current, opts.signal); if (state === "running") { result = { status: "already_running", reason: null }; + } else if (state === "untrusted") { + result = { status: "skipped", reason: "checkout_untrusted" }; } else if (state === "unknown") { result = { status: "skipped", reason: "runtime_unknown" }; } else { @@ -461,6 +525,14 @@ export function classifyAutostartExit(opts: { ) { return { status: "skipped", reason: detail.reason }; } + if ( + code === "E_PERMISSION" && + isRecord(detail) && + (detail.reason === "checkout_untrusted" || + detail.reason === "executable_path_untrusted") + ) { + return { status: "skipped", reason: detail.reason }; + } if (code === "E_PROJECT_NOT_FOUND") { return { status: "skipped", reason: "project_unavailable" }; } @@ -497,8 +569,11 @@ export async function autostartRoutingInstalled( ).exists(); } -function productionDependencies( - runtime: AutostartRuntimeOptions +export function createAutostartProductionDependencies( + runtime: AutostartRuntimeOptions, + checks?: { + readonly executablePath?: typeof assertAutostartExecutablePathTrusted; + } ): AutostartDependencies { const env = autostartServiceEnvironment(runtime); const invocation = [ @@ -511,7 +586,16 @@ function productionDependencies( ]), ]; return { - startupRefusal: () => autostartRuntimeRefusal(env), + startupRefusal: async () => { + try { + await (checks?.executablePath ?? assertAutostartExecutablePathTrusted)({ + path: env.PATH ?? "", + }); + } catch { + return "executable_path_untrusted"; + } + return autostartRuntimeRefusal(env); + }, now: Date.now, sleep: async (ms, signal) => { if (signal?.aborted) { @@ -613,7 +697,15 @@ async function inspectComposeProject( project: RegisteredProject, signal?: AbortSignal, env: Readonly> = {} -): Promise<"running" | "stopped" | "unknown"> { +): Promise<"running" | "stopped" | "unknown" | "untrusted"> { + try { + assertAutostartCheckoutTrusted({ + repoRoot: project.repoRoot, + projectDir: project.projectDir, + }); + } catch { + return "untrusted"; + } const context = await findProjectContextAtRoot({ projectRoot: project.repoRoot, projectDirName: project.projectDirName, @@ -857,8 +949,14 @@ async function readMachineBootId( } } if (process.platform === "darwin") { + const sysctl = "/usr/sbin/sysctl"; + if ( + (await checkServiceExecutableTrusted({ path: sysctl, uid: 0 })) !== null + ) { + return null; + } const result = await runAutostartSubprocess( - ["sysctl", "-n", "kern.boottime"], + [sysctl, "-n", "kern.boottime"], { timeoutMs: 2000, env, diff --git a/src/daemon/server.ts b/src/daemon/server.ts index b6a8050bf..51df974f4 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -27,6 +27,7 @@ import { type ControlPlaneConfig, readControlPlaneConfig, } from "../control-plane/sdk/config.ts"; +import { assertAutostartExecutablePathTrusted } from "../lib/autostart-executable-trust.ts"; import { ensureDir } from "../lib/fs.ts"; import { resolveRegisteredProjectById } from "../lib/projects-registry.ts"; import { runAutostartAtLogin } from "./autostart.ts"; @@ -96,6 +97,11 @@ export async function runDaemon({ await ensureDir(paths.root); // Before any socket is bound: the local API is unauthenticated. await secureDaemonDirectory({ root: paths.root }); + if (process.env.HACK_DAEMON_LOGIN_SERVICE === "1") { + await assertAutostartExecutablePathTrusted({ + path: process.env.PATH ?? "", + }); + } await removeFileIfExists({ path: paths.socketPath }); await writeDaemonPid({ pidPath: paths.pidPath, pid: process.pid }); diff --git a/src/lib/autostart-checkout-trust.ts b/src/lib/autostart-checkout-trust.ts new file mode 100644 index 000000000..fa71921e0 --- /dev/null +++ b/src/lib/autostart-checkout-trust.ts @@ -0,0 +1,39 @@ +import { statSync } from "node:fs"; +import { HackCliError } from "./cli-result.ts"; +import { checkReplaceableChain } from "./path-trust.ts"; + +/** + * Automatic startup may execute checkout hooks only through paths another user + * cannot replace. This checks live filesystem ownership and modes, not Git content; + * ordinary updates by the checkout owner remain allowed. No permissions are changed. + */ +export function assertAutostartCheckoutTrusted(opts: { + readonly repoRoot: string; + readonly projectDir: string; +}): void { + const refusal = () => + new HackCliError({ + code: "E_PERMISSION", + message: + "Autostart cannot trust this checkout path. Use a checkout whose directory chain is owned by the current user or root and is not replaceable by other users. No further startup effects ran. Values omitted.", + detail: { reason: "checkout_untrusted" }, + }); + const uid = process.getuid?.(); + if (uid === undefined || !Number.isSafeInteger(uid) || uid < 0) { + throw refusal(); + } + try { + const checked = new Set(); + for (const path of [opts.repoRoot, opts.projectDir]) { + if ( + checkReplaceableChain({ path, uid, hackHome: null, checked }) !== + null || + !statSync(path).isDirectory() + ) { + throw refusal(); + } + } + } catch { + throw refusal(); + } +} diff --git a/src/lib/autostart-executable-trust.ts b/src/lib/autostart-executable-trust.ts new file mode 100644 index 000000000..c0ff9dc23 --- /dev/null +++ b/src/lib/autostart-executable-trust.ts @@ -0,0 +1,28 @@ +import { checkServicePathDirectories } from "../daemon/systemd.ts"; +import { HackCliError } from "./cli-result.ts"; + +/** + * Login helper lookup has the same read-only ownership and mode requirements on + * both platforms. Check every lookup directory and its executable/symlink + * entries before any helper runs; inherited shell paths are never repaired. + */ +export async function assertAutostartExecutablePathTrusted(opts: { + readonly path: string; +}): Promise { + try { + if ( + (await checkServicePathDirectories({ dirs: opts.path.split(":") })) === + null + ) { + return; + } + } catch { + // Host filesystem diagnostics can carry private paths; never return them. + } + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Automatic startup refused an unsafe executable search path. Review directory and program ownership and permissions, then retry. Values omitted.", + detail: { reason: "executable_path_untrusted" }, + }); +} diff --git a/src/lib/autostart-project-lock.ts b/src/lib/autostart-project-lock.ts index ac364ae74..a3b2bbf5b 100644 --- a/src/lib/autostart-project-lock.ts +++ b/src/lib/autostart-project-lock.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { lstat, mkdir } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; import { HackCliError } from "./cli-result.ts"; import { readProjectsRegistry, @@ -71,7 +72,8 @@ export async function assertAutostartProjectCurrent(opts: { const projects = registry.projects.filter( (project) => project.id === opts.scope.projectId ); - const marker = projects.length === 1 ? projects[0]?.autostart : undefined; + const selected = projects.length === 1 ? projects[0] : undefined; + const marker = selected?.autostart; let reason: string | null = null; if (registry.autostartGlobal?.paused) { reason = "global_paused"; @@ -92,4 +94,10 @@ export async function assertAutostartProjectCurrent(opts: { detail: { reason }, }); } + if (opts.expectedRoot !== undefined && selected) { + assertAutostartCheckoutTrusted({ + repoRoot: selected.repoRoot, + projectDir: selected.projectDir, + }); + } } diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index 2266fcc7d..dbf18d8a6 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -1188,17 +1188,24 @@ export async function claimProjectAutostart( export async function recordProjectAutostartResult( opts: AutostartRegistryOptions & { readonly id: string; - readonly bootId: string; + /** Expected existing claim; null also supports a refusal before any claim. */ + readonly bootId: string | null; readonly result: ProjectAutostartResult; + readonly onlyIfWanted?: boolean; + readonly expectedBranch?: string | null; } ): Promise { let recorded = false; await mutateProjectAutostart({ ...opts, + respectGlobalPause: opts.onlyIfWanted, update: (project) => { if ( project.autostart?.invalid || - project.autostart?.lastAttemptBootId !== opts.bootId + project.autostart?.lastAttemptBootId !== opts.bootId || + (opts.onlyIfWanted && project.autostart?.paused) || + (opts.expectedBranch !== undefined && + project.autostart?.branch !== opts.expectedBranch) ) { return project; } diff --git a/tests/autostart-checkout-trust.test.ts b/tests/autostart-checkout-trust.test.ts new file mode 100644 index 000000000..6419ebe6e --- /dev/null +++ b/tests/autostart-checkout-trust.test.ts @@ -0,0 +1,260 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { assertAutostartCheckoutTrusted } from "../src/lib/autostart-checkout-trust.ts"; +import { + assertAutostartProjectCurrent, + withAutostartProjectLock, +} from "../src/lib/autostart-project-lock.ts"; +import { quoteShellWord } from "../src/lib/logs.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +let root = ""; +let repoRoot = ""; +let projectDir = ""; +let registryPath = ""; +const scope = { + projectId: "example-id", + composeProject: "example", + branch: null, +}; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-checkout-trust-")); + repoRoot = join(root, "projects", "example"); + projectDir = join(repoRoot, ".hack"); + registryPath = join(root, "home", ".hack", "projects.json"); + await mkdir(projectDir, { recursive: true, mode: 0o700 }); + await mkdir(join(root, "home", ".hack"), { recursive: true, mode: 0o700 }); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ name: "example" }) + ); + await writeFile( + join(projectDir, "docker-compose.yml"), + "services:\n app:\n image: example.invalid/app\n" + ); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: [ + { + id: scope.projectId, + name: "example", + repoRoot, + projectDir, + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00.000Z", + autostart: { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: null, + }, + }, + ], + }), + { mode: 0o600 } + ); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +test("private owned checkout paths remain trusted across owner Git updates", async () => { + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).not.toThrow(); + await mkdir(join(repoRoot, ".git"), { mode: 0o700 }); + await writeFile(join(repoRoot, ".git", "HEAD"), "ref: refs/heads/main\n"); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ + name: "example", + lifecycle: { up: { before: ["printf updated"] } }, + }) + ); + await writeFile(join(repoRoot, ".git", "HEAD"), "ref: refs/heads/updated\n"); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).not.toThrow(); +}); + +test("a real checkout replacement beneath a writable parent is refused without changing permissions", async () => { + const parent = join(root, "projects"); + await chmod(parent, 0o770); + await rename(repoRoot, join(parent, "retained-original")); + await mkdir(projectDir, { recursive: true, mode: 0o700 }); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ + name: "replacement", + lifecycle: { up: { before: ["printf replacement"] } }, + }) + ); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).toThrow(); + expect((await stat(parent)).mode & 0o777).toBe(0o770); +}); + +test("writable checkout or project directories and symlink target chains are refused read-only", async () => { + for (const path of [repoRoot, projectDir]) { + await chmod(path, 0o770); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).toThrow(); + expect((await stat(path)).mode & 0o777).toBe(0o770); + await chmod(path, 0o700); + } + const shared = join(root, "shared"); + const target = join(shared, "project-inputs"); + await mkdir(target, { recursive: true, mode: 0o700 }); + await chmod(shared, 0o770); + await rename(projectDir, join(repoRoot, "retained-inputs")); + await symlink(target, projectDir); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).toThrow(); + expect((await stat(shared)).mode & 0o777).toBe(0o770); +}); + +test("checkout trust refusals expose only a fixed reason without paths or OS values", () => { + try { + assertAutostartCheckoutTrusted({ + repoRoot: join(root, "missing"), + projectDir, + }); + throw new Error("Expected checkout trust refusal"); + } catch (error: unknown) { + expect(error).toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "checkout_untrusted" }, + }); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain("ENOENT"); + } +}); + +test("the locked login admission refuses a replaced checkout before its callback", async () => { + const parent = join(root, "projects"); + await chmod(parent, 0o770); + await rename(repoRoot, join(parent, "retained-original")); + await mkdir(projectDir, { recursive: true, mode: 0o700 }); + let callbackRan = false; + await expect( + withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + await assertAutostartProjectCurrent({ + scope, + registryPath, + expectedRoot: repoRoot, + }); + callbackRan = true; + }, + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "checkout_untrusted" }, + }); + expect(callbackRan).toBe(false); +}); + +test("a final pre-start recheck observes checkout trust changes after initial admission", async () => { + let startRan = false; + await expect( + withAutostartProjectLock({ + scope, + registryPath, + run: async () => { + await assertAutostartProjectCurrent({ + scope, + registryPath, + expectedRoot: repoRoot, + }); + await chmod(join(root, "projects"), 0o770); + await assertAutostartProjectCurrent({ + scope, + registryPath, + expectedRoot: repoRoot, + }); + startRan = true; + }, + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "checkout_untrusted" }, + }); + expect(startRan).toBe(false); +}); + +test("source login CLI never runs a checkout hook or engine command under a writable parent", async () => { + const hook = join(root, "hook-ran"); + const engine = join(root, "engine-ran"); + const bin = join(root, "bin"); + await mkdir(bin, { mode: 0o700 }); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ + name: "example", + lifecycle: { up: { before: [`printf ran > ${quoteShellWord(hook)}`] } }, + }) + ); + await writeFile( + join(bin, "docker"), + `#!${process.execPath}\nawait Bun.write(${JSON.stringify(engine)}, "ran");\nprocess.exit(99);\n`, + { mode: 0o700 } + ); + await chmod(join(root, "projects"), 0o770); + const child = Bun.spawn( + [ + process.execPath, + resolve("index.ts"), + "--no-interactive", + "up", + "--path", + repoRoot, + "-d", + "--json", + ], + { + cwd: root, + env: privateCliEnv({ + root, + home: join(root, "home"), + env: { + PATH: bin, + HACK_AUTOSTART_LOGIN: "1", + HACK_AUTOSTART_PROJECT_ID: scope.projectId, + }, + }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(code, stdout + stderr).not.toBe(0); + expect(stdout + stderr).toContain("checkout_untrusted"); + expect(await Bun.file(hook).exists()).toBe(false); + expect(await Bun.file(engine).exists()).toBe(false); + expect((await stat(join(root, "projects"))).mode & 0o777).toBe(0o770); +}); diff --git a/tests/autostart-executable-trust.test.ts b/tests/autostart-executable-trust.test.ts new file mode 100644 index 000000000..f7719a727 --- /dev/null +++ b/tests/autostart-executable-trust.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertAutostartExecutablePathTrusted } from "../src/lib/autostart-executable-trust.ts"; + +let root = ""; +let bin = ""; +let program = ""; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-executable-trust-")); + bin = join(root, "bin"); + program = join(bin, "docker"); + await mkdir(bin, { mode: 0o700 }); + await writeFile(program, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +test("safe owned lookup directories and programs are accepted without execution", async () => { + await expect( + assertAutostartExecutablePathTrusted({ path: bin }) + ).resolves.toBeUndefined(); + expect(await readFile(program, "utf8")).toBe("#!/bin/sh\nexit 0\n"); +}); + +test("writable lookup directories and ancestor paths refuse without permission repair", async () => { + await chmod(bin, 0o777); + await expect( + assertAutostartExecutablePathTrusted({ path: bin }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "executable_path_untrusted" }, + }); + expect((await Bun.file(program).stat()).mode & 0o777).toBe(0o700); + await chmod(bin, 0o700); + await chmod(root, 0o777); + await expect( + assertAutostartExecutablePathTrusted({ path: bin }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await chmod(root, 0o700); +}); + +test("writable helper entries and symlink targets refuse even with private lookup directories", async () => { + await chmod(program, 0o777); + await expect( + assertAutostartExecutablePathTrusted({ path: bin }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await rm(program); + const target = join(root, "target"); + await writeFile(target, "#!/bin/sh\nexit 0\n", { mode: 0o777 }); + await chmod(target, 0o777); + await symlink(target, program); + await expect( + assertAutostartExecutablePathTrusted({ path: bin }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await chmod(target, 0o700); + await expect( + assertAutostartExecutablePathTrusted({ path: bin }) + ).resolves.toBeUndefined(); +}); + +test("empty or relative lookup entries refuse with fixed value-free errors", async () => { + for (const path of ["", `${bin}:`, "private-sentinel/relative"]) { + let error: unknown; + try { + await assertAutostartExecutablePathTrusted({ path }); + } catch (failure) { + error = failure; + } + expect(error).toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "executable_path_untrusted" }, + }); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain("private-sentinel"); + } +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index de09f47a0..acbce5a00 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -13,10 +13,12 @@ import { classifyAutostartContainers, classifyAutostartExit, completedDependencyServices, + createAutostartProductionDependencies, resolveAutostartEngineWaitMs, runAutostartAtLogin, runAutostartSubprocess, } from "../src/daemon/autostart.ts"; +import { assertAutostartExecutablePathTrusted } from "../src/lib/autostart-executable-trust.ts"; import { beginAutostartLoginRun, claimProjectAutostart, @@ -252,6 +254,23 @@ test("engine wait uses bounded exponential backoff and leaves attempts unclaimed }); }); +test("a failed boot probe preserves prior claims and cannot authorize replay when identity recovers", async () => { + await seed(["alpha"]); + await run(); + expect(starts).toHaveLength(1); + deps = { ...deps, bootId: async () => null }; + await run(); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ).toMatchObject({ + lastAttemptBootId: "boot-1", + lastResult: { reason: "boot_identity_unavailable" }, + }); + deps = { ...deps, bootId: async () => "boot-1" }; + await run(); + expect(starts).toHaveLength(1); +}); + test("routing failure, unknown runtime and unavailable boot identity fail closed", async () => { await seed(["alpha"]); deps = { ...deps, startRouting: async () => false }; @@ -1050,3 +1069,84 @@ test("service PATH derives known user install directories from its owned home an } } }); + +test("production executable trust refuses a poisoned home bin before engine helpers execute", async () => { + await seed(["alpha"]); + const bin = join(root, ".bun/bin"); + await mkdir(bin, { recursive: true, mode: 0o700 }); + const receipt = join(root, "docker-executed"); + await writeFile( + join(bin, "docker"), + `#!/bin/sh\nprintf invoked > '${receipt}'\n`, + { mode: 0o700 } + ); + await chmod(bin, 0o777); + const production = createAutostartProductionDependencies( + { home: root, hackHome: join(root, "state") }, + { + executablePath: async ({ path }) => { + expect(path.split(":")).toContain(bin); + await assertAutostartExecutablePathTrusted({ path: bin }); + }, + } + ); + deps = { + ...deps, + startupRefusal: production.startupRefusal, + bootId: async () => { + events.push("boot"); + return "boot-1"; + }, + }; + await run(); + expect(events).toEqual(["boot"]); + expect(starts).toEqual([]); + expect(await Bun.file(receipt).exists()).toBe(false); + expect((await results())[0]?.result).toMatchObject({ + status: "skipped", + reason: "executable_path_untrusted", + }); +}); + +test("untrusted checkout observations and child permission refusals are closed skips", async () => { + await seed(["alpha"]); + deps = { ...deps, inspect: async () => "untrusted" }; + await run(); + expect(starts).toEqual([]); + expect((await results())[0]?.result).toMatchObject({ + status: "skipped", + reason: "checkout_untrusted", + }); + for (const reason of ["checkout_untrusted", "executable_path_untrusted"]) { + expect( + classifyAutostartExit({ + exitCode: 1, + stdout: JSON.stringify({ + error: { code: "E_PERMISSION", detail: { reason } }, + }), + }) + ).toEqual({ status: "skipped", reason }); + } +}); + +test("a refused executable path never overwrites an earlier real-boot claim or permits duplicate starts", async () => { + await seed(["alpha"]); + await run(); + const before = (await readProjectsRegistry({ registryPath })).projects[0] + ?.autostart; + expect(before?.lastAttemptBootId).toBe("boot-1"); + deps = { + ...deps, + startupRefusal: async () => "executable_path_untrusted" as const, + }; + await run(); + const refused = (await readProjectsRegistry({ registryPath })).projects[0] + ?.autostart; + expect(refused).toEqual(before); + deps = { ...deps, startupRefusal: async () => null }; + await run(); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ).toEqual(before); +}); diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index ff3bee67a..e924669ad 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -1101,6 +1101,33 @@ for (const operation of ["register", "prune"] as const) { }); } +test("a refusal without a boot claim preserves admission and cannot bypass a pause or recreate intent", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + const refusal = { + id: "alpha", + registryPath, + bootId: null, + expectedBranch: null, + onlyIfWanted: true, + result: { + status: "skipped" as const, + reason: "boot_identity_unavailable", + at: "2026-01-01T00:00:00.000Z", + }, + }; + expect(await recordProjectAutostartResult(refusal)).toBe(true); + expect((await read())?.autostart?.lastAttemptBootId).toBeNull(); + await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); + expect(await recordProjectAutostartResult(refusal)).toBe(false); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + await setAutostartGlobalPaused({ paused: true, registryPath }); + expect(await recordProjectAutostartResult(refusal)).toBe(false); + await setAutostartGlobalPaused({ paused: false, registryPath }); + await removeProjectAutostart({ id: "alpha", registryPath }); + expect(await recordProjectAutostartResult(refusal)).toBe(false); + expect((await read())?.autostart).toBeUndefined(); +}); + test("ordinary registration replaces an unmarked registry symlink without changing its target", async () => { const project = await createTrustProject(); const target = join(root, "linked-legacy-registry.json"); From 75d5ed26a73a334b785f9a8f87c4f69525f51703 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:14:44 +0000 Subject: [PATCH 14/44] fix(autostart): settle starts and authenticate runtime inputs --- docs/always-on-projects.md | 39 +++- src/backends/runtime-backend.ts | 2 + src/commands/global-autostart.ts | 6 +- src/commands/project-autostart-intent.ts | 3 + src/daemon/autostart.ts | 69 ++++++- src/daemon/server.ts | 4 +- src/lib/autostart-checkout-trust.ts | 29 ++- src/lib/autostart-docker-trust.ts | 65 +++++++ src/lib/autostart-executable-trust.ts | 16 ++ src/lib/autostart-project-lock.ts | 5 + src/lib/autostart-routing-assets.ts | 48 ++++- src/lib/process-group-settlement.ts | 95 ++++++++++ src/lib/shell.ts | 34 +++- tests/autostart-checkout-trust.test.ts | 43 ++++- tests/autostart-docker-trust.test.ts | 46 +++++ tests/autostart-executable-trust.test.ts | 19 +- tests/autostart-routing-assets.test.ts | 79 +++++++- tests/daemon-autostart.test.ts | 121 +++++++++++++ .../tla/autostart-start-stop/COVERAGE.md | 8 + tests/runtime-backend-cancellation.test.ts | 171 ++++++++++++++++++ tests/runtime-backend.test.ts | 12 ++ tests/shell-abort.test.ts | 39 ++++ 22 files changed, 914 insertions(+), 39 deletions(-) create mode 100644 src/lib/autostart-docker-trust.ts create mode 100644 src/lib/process-group-settlement.ts create mode 100644 tests/autostart-docker-trust.test.ts create mode 100644 tests/runtime-backend-cancellation.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index f6762d4a2..e1c156863 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -69,11 +69,15 @@ Children receive a fixed environment allow-list and an executable search path built from system directories and known Bun, Hack and Docker directories under the owned home (plus Linux `.local/bin`); launching-shell `DOCKER_*`, `COMPOSE_*` and `HACK_*` overrides are not copied. Hack adds only its explicit internal noninteractive and identity settings. -Every login-service launch checks its effective executable lookup path before -starting an engine watcher. Explicit passes check the derived path before helper +Every login-service launch removes unsafe directories from its executable lookup +path before starting an engine watcher; it keeps serving the local API when an +optional tool directory is unsafe. Explicit passes check the derived path before helper lookup too. Directories, executable files and symlink targets must satisfy the same read-only ownership and permission checks used by the Linux service; unsafe -paths are refused without automatic permission repair. +paths are refused without automatic permission repair. This strict policy also +applies to group-writable Homebrew directories, including group `admin`: install +required helpers in a trusted lookup directory before retrying. A refused search +path does not consume a project's attempt for this boot. Already-running instances are adopted without repeating startup hooks. An unknown runtime state is skipped rather than taken as proof that the instance is down. @@ -81,7 +85,8 @@ Automatic routing accepts only the already-installed files produced by this CLI's current Caddy, CoreDNS, logging, Grafana and schema templates. It checks ownership, file type and every directory component, refuses symlinks and unsafe parent directories, and compares bounded reads with those templates. All files -must pass before matching bytes are republished on fresh `0600` inodes. Routing +must pass before matching non-secret bytes are republished on fresh `0644` inodes, +so container users can read them while other host users cannot write them. Routing and logging containers are recreated to bind those new files; an old open writer or old file mount cannot modify the new assets. Compose `.env` imports and the unsupported remote-route bridge are disabled for this automatic path. @@ -89,7 +94,9 @@ Customized, outdated, missing or unsafe assets cause `routing_failed` and no project starts. This path does not install or migrate assets. Review the paths, preserve suspect files outside the active installation, secure their parent directories, and regenerate the installed files with `hack global install` -before another login pass. Ordinary manual routing commands keep their behavior. +before another login pass. Unexpected files in the mounted `schemas` or Grafana +`dashboards` directories must be reviewed and moved outside those directories; +reinstallation alone does not remove extra files. Ordinary manual routing commands keep their behavior. Adoption requires a running container. An exited-zero container is treated as a completed job only when its service is a dependency target explicitly declared with `condition: service_completed_successfully`; other stopped services require @@ -99,12 +106,24 @@ runs the normal `up` path. This slice reads the primary Compose file; branch overrides, declared `scale` or `deploy.replicas`, explicit Compose file/profile overrides and unsupported composition cannot prove adoption, so they run ordinary `up` and may repeat startup hooks. +Managed environment metadata and dotenv inputs also require ordinary `up`; +adoption never decrypts environment values to guess the selected graph. Before project input discovery and again under the instance start lock, login checks that the checkout and project-directory ancestor chains belong to the current user or root and cannot be replaced by other users. The check repeats before container startup. A shared writable parent is refused even when the -saved registry pathname still matches. Normal owner Git updates remain allowed. +saved registry pathname still matches. Startup configuration, Compose and environment +input files and their symlink targets also require trusted ownership and modes; +protected directories alone do not make a writable lifecycle configuration safe. +Normal owner Git updates remain allowed. This is not a recursive audit of all +repository code a hook or build may execute. + +Docker's `config.json` and context metadata/TLS paths must likewise be owned by +the current user or root and not writable by other users. These checks inspect +filesystem metadata without reading credentials, and repeat in login children +before runtime startup. Unsafe engine selection refuses without consuming a boot +attempt; owner-controlled context changes remain supported. No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a @@ -144,8 +163,10 @@ without replacing a prior claim, so recovering the probe cannot replay an already-attempted project. Resume permits the next login attempt; use `hack up` to start immediately. A routing failure consumes that boot's attempt; an engine timeout does not. -A start has a 120-second bound, then receives TERM and a two-second grace before -KILL. Partial startup remains possible; inspect and recover with `hack up`. +A start has a 120-second bound, then receives TERM and a five-second grace before +KILL. Detached Compose phases forward cancellation and settle their own process +groups before releasing startup ownership. Partial startup remains possible; +inspect and recover with `hack up`. `HACK_DAEMON_AUTOSTART_DISABLE=1` disables this pass as well as implicit daemon startup. Test harnesses and the isolated `hack-v5` launcher set it; those runs do @@ -241,7 +262,7 @@ Current skip reasons are `engine_timeout`, `routing_failed`, `boot_identity_unavailable`, `native_unsupported`, `runtime_unknown`, `interactive_required`, `env_key_missing`, `keychain_unavailable`, `pending_env_migration`, `project_unavailable`, `checkout_untrusted`, -`executable_path_untrusted`, `paused`, `target_changed`, +`executable_path_untrusted`, `docker_config_untrusted`, `paused`, `target_changed`, `daemon_stopped`, `global_paused`, and `invalid_marker`. Failure reasons are `start_failed`, `start_timeout`, and `output_limit`. diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index 9243c17ff..527f8457f 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -173,6 +173,8 @@ export const composeRuntimeBackend: RuntimeBackend = { env: opts.env, stdout: opts.routeStdoutToStderr ? "stderr" : "inherit", timeoutMs, + forwardSignals: true, + settleProcessGroupOnCancel: true, }); // Image build/pull can take far longer than starting containers, so it // runs (with container creation/recreation) under its own budget. The diff --git a/src/commands/global-autostart.ts b/src/commands/global-autostart.ts index 41fa71a76..e3bbf263a 100644 --- a/src/commands/global-autostart.ts +++ b/src/commands/global-autostart.ts @@ -13,6 +13,7 @@ export async function secureGlobalLoginRouting(opts: { if (!opts.login) { return; } + assertAutostartDockerConfigTrusted({ home: homedir() }); try { await secureAutostartRoutingAssets({ root: opts.root, @@ -23,7 +24,7 @@ export async function secureGlobalLoginRouting(opts: { throw new HackCliError({ code: "E_PERMISSION", message: - "Automatic routing refused untrusted or outdated installed assets. Review the routing paths and reinstall current assets before retrying. Values omitted.", + "Automatic routing refused untrusted or outdated installed assets. Review and move unexpected schema/dashboard files aside, then reinstall current assets before retrying. Values omitted.", detail: { reason: "routing_assets_untrusted" }, }); } @@ -65,3 +66,6 @@ export async function reconcileGlobalRoutingBridge(opts: { }); } } + +import { homedir } from "node:os"; +import { assertAutostartDockerConfigTrusted } from "../lib/autostart-docker-trust.ts"; diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 0487ab2dd..39145d926 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -1,4 +1,5 @@ import { realpath } from "node:fs/promises"; +import { homedir } from "node:os"; import { resolve } from "node:path"; import { CliUsageError } from "../cli/command.ts"; import { parseProjectSelectorOption } from "../cli/project-selector.ts"; @@ -7,6 +8,7 @@ import { PROJECT_ENV_FILENAME, } from "../constants.ts"; import { assertAutostartCheckoutTrusted } from "../lib/autostart-checkout-trust.ts"; +import { assertAutostartDockerConfigTrusted } from "../lib/autostart-docker-trust.ts"; import { assertAutostartProjectCurrent, withAutostartProjectLock, @@ -343,6 +345,7 @@ function assertLoginCheckoutBeforeDiscovery(opts: { }); } assertAutostartCheckoutTrusted(project); + assertAutostartDockerConfigTrusted({ home: homedir() }); } function assertLoginProjectIdentity(opts: { diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index 12442618b..4b5e01411 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { readdir } from "node:fs/promises"; import { userInfo } from "node:os"; import { isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,8 +9,13 @@ import { GLOBAL_CADDY_COMPOSE_FILENAME, GLOBAL_CADDY_DIR_NAME, GLOBAL_PROJECTS_REGISTRY_FILENAME, + PROJECT_ENV_CONFIG_FILENAME_PREFIX, + PROJECT_ENV_CONFIG_FILENAME_SUFFIX, + PROJECT_ENV_CONTRACT_FILENAME, + PROJECT_ENV_FILENAME, } from "../constants.ts"; import { assertAutostartCheckoutTrusted } from "../lib/autostart-checkout-trust.ts"; +import { assertAutostartDockerConfigTrusted } from "../lib/autostart-docker-trust.ts"; import { assertAutostartExecutablePathTrusted } from "../lib/autostart-executable-trust.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { readTextFile } from "../lib/fs.ts"; @@ -38,6 +44,7 @@ type StartResult = Omit; type AutostartStartupRefusal = | "native_unsupported" | "executable_path_untrusted" + | "docker_config_untrusted" | null; /** Side effects are explicit so ordering, bounds and restart recovery can be tested without an engine. */ @@ -162,7 +169,7 @@ async function runAutostartLoginPass(opts: { } let waitingForEngine = false; try { - if (machineBootId === null) { + if (machineBootId === null || startupRefusal) { await recordUnclaimedAutostartRefusal({ pending, runId: loginRun.runId, @@ -529,7 +536,8 @@ export function classifyAutostartExit(opts: { code === "E_PERMISSION" && isRecord(detail) && (detail.reason === "checkout_untrusted" || - detail.reason === "executable_path_untrusted") + detail.reason === "executable_path_untrusted" || + detail.reason === "docker_config_untrusted") ) { return { status: "skipped", reason: detail.reason }; } @@ -594,6 +602,11 @@ export function createAutostartProductionDependencies( } catch { return "executable_path_untrusted"; } + try { + assertAutostartDockerConfigTrusted({ home: runtime.home }); + } catch { + return "docker_config_untrusted"; + } return autostartRuntimeRefusal(env); }, now: Date.now, @@ -706,6 +719,18 @@ async function inspectComposeProject( } catch { return "untrusted"; } + // An uncertain selected graph must run ordinary up rather than adopt a subset. + if ( + project.autostart?.branch || + env.COMPOSE_FILE || + env.COMPOSE_PROFILES || + !(await canAdoptAutostartEnvironment({ + projectRoot: project.repoRoot, + projectDir: project.projectDir, + })) + ) { + return "stopped"; + } const context = await findProjectContextAtRoot({ projectRoot: project.repoRoot, projectDirName: project.projectDirName, @@ -717,10 +742,6 @@ async function inspectComposeProject( if (cfg.parseError) { return "unknown"; } - // An uncertain selected graph must run ordinary up rather than adopt a subset. - if (project.autostart?.branch || env.COMPOSE_FILE || env.COMPOSE_PROFILES) { - return "stopped"; - } const model = await readAutostartComposeModel(context.composeFile); if (!model) { return "stopped"; @@ -770,6 +791,38 @@ async function inspectComposeProject( ); } +/** + * Managed global/project values can select a different Compose graph, including + * values reachable only through secret references. Presence is enough to decline + * adoption: observation never decrypts layers or asks a keychain for graph values. + * Ordinary up remains the authority for resolving those sources and overlays. + */ +export async function canAdoptAutostartEnvironment(opts: { + readonly projectRoot: string; + readonly projectDir: string; +}): Promise { + try { + for (const directory of new Set([opts.projectRoot, opts.projectDir])) { + const entries = await readdir(directory); + if ( + entries.some( + (name) => + name === PROJECT_ENV_CONTRACT_FILENAME || + name === PROJECT_ENV_FILENAME || + name.startsWith(`${PROJECT_ENV_FILENAME}.`) || + (name.startsWith(PROJECT_ENV_CONFIG_FILENAME_PREFIX) && + name.endsWith(PROJECT_ENV_CONFIG_FILENAME_SUFFIX)) + ) + ) { + return false; + } + } + return true; + } catch { + return false; + } +} + /** Only declared successful-completion dependency targets may be adopted after exit. */ export function classifyAutostartContainers( text: string, @@ -1077,7 +1130,9 @@ export async function runAutostartSubprocess( reason ??= failure; stopping ??= terminateAutostartGroup({ pid: proc.pid, - graceMs: opts.terminationGraceMs ?? 2000, + // Inner runtime phases get two seconds to kill their separately owned + // groups and unwind while the login CLI still holds the startup lock. + graceMs: opts.terminationGraceMs ?? 5000, }); }; const timer = setTimeout(() => stop("start_timeout"), opts.timeoutMs); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 51df974f4..b2d3320d1 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -27,7 +27,7 @@ import { type ControlPlaneConfig, readControlPlaneConfig, } from "../control-plane/sdk/config.ts"; -import { assertAutostartExecutablePathTrusted } from "../lib/autostart-executable-trust.ts"; +import { trustedAutostartServicePath } from "../lib/autostart-executable-trust.ts"; import { ensureDir } from "../lib/fs.ts"; import { resolveRegisteredProjectById } from "../lib/projects-registry.ts"; import { runAutostartAtLogin } from "./autostart.ts"; @@ -98,7 +98,7 @@ export async function runDaemon({ // Before any socket is bound: the local API is unauthenticated. await secureDaemonDirectory({ root: paths.root }); if (process.env.HACK_DAEMON_LOGIN_SERVICE === "1") { - await assertAutostartExecutablePathTrusted({ + process.env.PATH = await trustedAutostartServicePath({ path: process.env.PATH ?? "", }); } diff --git a/src/lib/autostart-checkout-trust.ts b/src/lib/autostart-checkout-trust.ts index fa71921e0..50e5d8152 100644 --- a/src/lib/autostart-checkout-trust.ts +++ b/src/lib/autostart-checkout-trust.ts @@ -1,6 +1,10 @@ -import { statSync } from "node:fs"; +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; import { HackCliError } from "./cli-result.ts"; -import { checkReplaceableChain } from "./path-trust.ts"; +import { checkReplaceableChain, walkReplaceableChain } from "./path-trust.ts"; + +const STARTUP_INPUT = + /^(?:hack\.(?:config|env)(?:\..*)?\.(?:json|ya?ml)|docker-compose(?:\..*)?\.ya?ml|\.env(?:\..*)?)$/; /** * Automatic startup may execute checkout hooks only through paths another user @@ -33,6 +37,27 @@ export function assertAutostartCheckoutTrusted(opts: { throw refusal(); } } + // These declarative inputs can supply lifecycle commands, Compose graphs, + // and process environment. Check file modes and symlink targets as well as + // their directory chains, before any parser reads their contents. + const inputs = new Set([join(opts.repoRoot, ".env")]); + for (const name of readdirSync(opts.projectDir)) { + if (STARTUP_INPUT.test(name)) { + inputs.add(join(opts.projectDir, name)); + } + } + for (const path of inputs) { + const result = walkReplaceableChain({ + path, + uid, + hackHome: null, + checked, + allowMissing: true, + }); + if ("error" in result || (result.complete && !statSync(path).isFile())) { + throw refusal(); + } + } } catch { throw refusal(); } diff --git a/src/lib/autostart-docker-trust.ts b/src/lib/autostart-docker-trust.ts new file mode 100644 index 000000000..fc0340ffd --- /dev/null +++ b/src/lib/autostart-docker-trust.ts @@ -0,0 +1,65 @@ +import { readdirSync, realpathSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { HackCliError } from "./cli-result.ts"; +import { walkReplaceableChain } from "./path-trust.ts"; + +/** Authenticate Docker's engine-selection inputs without reading credentials. */ +export function assertAutostartDockerConfigTrusted(opts: { + readonly home: string; +}): void { + try { + const uid = process.getuid?.(); + if (uid === undefined) { + throw new Error("Unavailable owner"); + } + const root = join(opts.home, ".docker"); + const checked = new Set(); + const visited = new Set(); + let count = 0; + const visit = (path: string, depth: number): void => { + count += 1; + if (count > 4096 || depth > 16) { + throw new Error("Selection inputs exceed bound"); + } + const trust = walkReplaceableChain({ + path, + uid, + hackHome: null, + checked, + allowMissing: true, + }); + if ("error" in trust) { + throw new Error("Unsafe selection input"); + } + if (!trust.complete) { + return; + } + const info = statSync(path); + if (info.isFile()) { + return; + } + if (!info.isDirectory()) { + throw new Error("Unsupported selection input"); + } + const physical = realpathSync(path); + if (visited.has(physical)) { + throw new Error("Cyclic selection input"); + } + visited.add(physical); + for (const name of readdirSync(path)) { + visit(join(path, name), depth + 1); + } + }; + // Docker loads context metadata/TLS below this directory. Other .docker + // caches do not select an engine and can be arbitrarily large. + visit(join(root, "config.json"), 0); + visit(join(root, "contexts"), 0); + } catch { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Automatic startup refused unsafe Docker engine-selection files. Review Docker configuration ownership and permissions before retrying. Values omitted.", + detail: { reason: "docker_config_untrusted" }, + }); + } +} diff --git a/src/lib/autostart-executable-trust.ts b/src/lib/autostart-executable-trust.ts index c0ff9dc23..be89aa613 100644 --- a/src/lib/autostart-executable-trust.ts +++ b/src/lib/autostart-executable-trust.ts @@ -1,6 +1,22 @@ import { checkServicePathDirectories } from "../daemon/systemd.ts"; import { HackCliError } from "./cli-result.ts"; +/** Omit unsafe service lookup directories without preventing daemon startup. */ +export async function trustedAutostartServicePath(opts: { + readonly path: string; +}): Promise { + const trusted: string[] = []; + for (const directory of opts.path.split(":")) { + try { + await assertAutostartExecutablePathTrusted({ path: directory }); + trusted.push(directory); + } catch { + // A missing or unsafe optional tool directory is not a daemon failure. + } + } + return trusted.join(":"); +} + /** * Login helper lookup has the same read-only ownership and mode requirements on * both platforms. Check every lookup directory and its executable/symlink diff --git a/src/lib/autostart-project-lock.ts b/src/lib/autostart-project-lock.ts index a3b2bbf5b..d56f15460 100644 --- a/src/lib/autostart-project-lock.ts +++ b/src/lib/autostart-project-lock.ts @@ -1,7 +1,9 @@ import { createHash } from "node:crypto"; import { lstat, mkdir } from "node:fs/promises"; +import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; +import { assertAutostartDockerConfigTrusted } from "./autostart-docker-trust.ts"; import { HackCliError } from "./cli-result.ts"; import { readProjectsRegistry, @@ -95,6 +97,9 @@ export async function assertAutostartProjectCurrent(opts: { }); } if (opts.expectedRoot !== undefined && selected) { + if (process.env.HACK_AUTOSTART_LOGIN === "1") { + assertAutostartDockerConfigTrusted({ home: homedir() }); + } assertAutostartCheckoutTrusted({ repoRoot: selected.repoRoot, projectDir: selected.projectDir, diff --git a/src/lib/autostart-routing-assets.ts b/src/lib/autostart-routing-assets.ts index 5aa7a49e6..dbab0efd8 100644 --- a/src/lib/autostart-routing-assets.ts +++ b/src/lib/autostart-routing-assets.ts @@ -53,12 +53,17 @@ export type AutostartRoutingAssetsOptions = { readonly root: string; readonly bindAddress: string | null; readonly defaultDomain: string; + /** Observe staging before final identity/device revalidation; this does not authorize publication. */ + readonly beforePublish?: (opts: { + readonly stagingPath: string; + readonly targetPath: string; + }) => Promise; }; const MAX_ASSET_BYTES = 1024 * 1024; /** * Login executes only already-installed current templates. Authentication is - * read-only; publication replaces matching bytes on private fresh inodes so an + * read-only; publication replaces matching nonsecret bytes on fresh 0644 inodes so an * older writable descriptor cannot change what the upcoming Compose run reads. * Missing/custom assets refuse; this is neither installation nor migration. */ @@ -95,14 +100,23 @@ export async function secureAutostartRoutingAssets( for (const asset of assets) { observed.push(await readCurrentAsset({ asset, uid })); } + const stagingRoot = await lstat(opts.root); + if (observed.some((asset) => asset.stat.dev !== stagingRoot.dev)) { + throw new Error("Assets require a common filesystem"); + } for (const asset of observed) { - await publishFreshAsset({ asset, root: opts.root, uid }); + await publishFreshAsset({ + asset, + root: opts.root, + uid, + beforePublish: opts.beforePublish, + }); } } catch { throw new HackCliError({ code: "E_PERMISSION", message: - "Automatic routing startup refused untrusted or outdated installed assets. Run hack global install manually, then retry. Values omitted.", + "Automatic routing startup refused untrusted or outdated installed assets. Review and move unexpected schema/dashboard files aside before running hack global install manually, then retry. Values omitted.", detail: { reason: "routing_assets_untrusted" }, }); } @@ -274,6 +288,7 @@ async function publishFreshAsset(opts: { readonly asset: ObservedAsset; readonly root: string; readonly uid: number; + readonly beforePublish?: AutostartRoutingAssetsOptions["beforePublish"]; }): Promise { const { asset } = opts; await assertSafeAssetParent({ ...opts, path: asset.path }); @@ -281,16 +296,33 @@ async function publishFreshAsset(opts: { if (!(current.isFile() && sameObservedFile(asset.stat, current))) { throw new Error("Asset changed"); } - const staging = resolve( - dirname(asset.path), - `.autostart-routing-${randomUUID()}` - ); + // A killed publisher may leave this file behind. Keep it outside every + // mounted directory so a later login's exact-list check can still succeed. + const staging = resolve(opts.root, `.autostart-routing-${randomUUID()}`); + await assertSafeAssetParent({ ...opts, path: staging }); const file = await open(staging, "wx", 0o600); try { await file.writeFile(asset.text, "utf8"); - await file.chmod(0o600); + // These exact canonical templates hold no secrets. Container service UIDs + // need other-read permission on their bind-mounted fresh inode. + await file.chmod(0o644); await file.sync(); + await opts.beforePublish?.({ + stagingPath: staging, + targetPath: asset.path, + }); await assertSafeAssetParent({ ...opts, path: asset.path }); + const staged = await file.stat(); + const stagedPath = await lstat(staging); + if ( + !staged.isFile() || + staged.uid !== opts.uid || + (staged.mode & 0o777) !== 0o644 || + staged.dev !== current.dev || + !sameObservedFile(staged, stagedPath) + ) { + throw new Error("Staging identity or filesystem changed"); + } if (!sameObservedFile(current, await lstat(asset.path))) { throw new Error("Asset changed"); } diff --git a/src/lib/process-group-settlement.ts b/src/lib/process-group-settlement.ts new file mode 100644 index 000000000..73e7ad997 --- /dev/null +++ b/src/lib/process-group-settlement.ts @@ -0,0 +1,95 @@ +import { Buffer } from "node:buffer"; +import { isRecord } from "./guards.ts"; + +const MAX_CENSUS_BYTES = 1024 * 1024; +const PROCESS_GROUP_STATE = /^\s*(\d+)\s+(\S+)\s*$/; + +/** + * Observe an invocation-owned group after cancellation, without signalling a + * saved or reacquired PID. Zombies cannot execute further startup effects. + * A missing/failed census is unknown, never proof that the group is settled. + */ +export async function waitForOwnedProcessGroupSettlement(opts: { + readonly processGroupId: number; + readonly timeoutMs?: number; +}): Promise { + const deadline = performance.now() + (opts.timeoutMs ?? 2000); + while (performance.now() < deadline) { + const remaining = Math.max(1, deadline - performance.now()); + const state = await observeOwnedGroup(opts.processGroupId, remaining); + if (state === "settled") { + return true; + } + await Bun.sleep(Math.min(20, Math.max(1, deadline - performance.now()))); + } + return false; +} + +async function observeOwnedGroup( + group: number, + remaining: number +): Promise<"settled" | "running" | "unknown"> { + try { + process.kill(-group, 0); + } catch (error: unknown) { + if (isRecord(error) && error.code === "ESRCH") { + return "settled"; + } + } + try { + const child = Bun.spawn(["/bin/ps", "-A", "-o", "pgid=,stat="], { + env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + timeout: remaining, + }); + const [output, code] = await Promise.all([ + readBoundedCensus(child.stdout, () => child.kill("SIGKILL")), + child.exited, + ]); + if (code !== 0 || output === null || !output.trim()) { + return "unknown"; + } + for (const line of output.trim().split("\n")) { + const match = PROCESS_GROUP_STATE.exec(line); + if (!match) { + return "unknown"; + } + if (Number(match[1]) === group && !match[2]?.startsWith("Z")) { + return "running"; + } + } + return "settled"; + } catch { + return "unknown"; + } +} + +/** The census exposes only group/state columns, under one fixed byte bound. */ +async function readBoundedCensus( + stream: ReadableStream, + onOverflow: () => void +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + size += next.value.byteLength; + if (size > MAX_CENSUS_BYTES) { + onOverflow(); + await reader.cancel(); + return null; + } + chunks.push(next.value); + } + return Buffer.concat(chunks).toString("utf8"); + } finally { + reader.releaseLock(); + } +} diff --git a/src/lib/shell.ts b/src/lib/shell.ts index 28fc4186e..e0344d975 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -7,6 +7,7 @@ import { nativeCpuCommandCategory, } from "./native-cpu-diagnostics.ts"; import { boundedTimeoutMs } from "./process-deadline.ts"; +import { waitForOwnedProcessGroupSettlement } from "./process-group-settlement.ts"; import { readSubprocessResourceUsage } from "./process-resource-usage.ts"; import { hasControllingTerminal } from "./tty-process-group.ts"; @@ -157,6 +158,9 @@ export interface RunOptions { readonly timeoutMs?: number; /** Forward cancellation to an owned command process group, preserving TTY input. */ readonly forwardSignals?: boolean; + /** Refuse cancellation settlement until the captured group has no live members. + * Opt-in for startup phases whose owner must keep its project lock through cleanup. */ + readonly settleProcessGroupOnCancel?: boolean; /** * Cancel the same owned process group as OS forwarding. Pre-aborted admission * returns 143 without spawning or invoking observations. The reason is private. @@ -218,6 +222,28 @@ export async function run( return 143; } const beforeSpawn = options.beforeSpawn; + let capturedGroup: number | undefined; + const onSpawn: RunOptions["onSpawn"] = async (event) => { + if (event.ownsProcessGroup) { + capturedGroup = event.processGroupId ?? event.pid; + } + await options.onSpawn?.(event); + }; + const onExit = async (event: RunExitEvent) => { + if ( + options.settleProcessGroupOnCancel && + (event.cancelled || event.timedOut) && + (capturedGroup === undefined || + !(await waitForOwnedProcessGroupSettlement({ + processGroupId: capturedGroup, + }))) + ) { + throw new Error( + "Cancelled subprocess group settlement could not be confirmed; no successful startup result was recorded. Values omitted." + ); + } + await options.onExit?.(event); + }; if ( options.forwardSignals && (process.stdin.isTTY || hasControllingTerminal()) @@ -234,8 +260,8 @@ export async function run( timeoutMs: options.timeoutMs, signal, beforeSpawn, - onSpawn: options.onSpawn, - onExit: options.onExit, + onSpawn, + onExit, }); } const ownsProcessGroup = @@ -321,9 +347,9 @@ export async function run( }; const [result] = await Promise.all([ completion, - options.onSpawn?.({ pid: proc.pid, ownsProcessGroup, resumeSuspended }), + onSpawn({ pid: proc.pid, ownsProcessGroup, resumeSuspended }), ]); - await options.onExit?.(result); + await onExit(result); return result.exitCode; } diff --git a/tests/autostart-checkout-trust.test.ts b/tests/autostart-checkout-trust.test.ts index 6419ebe6e..46dae0485 100644 --- a/tests/autostart-checkout-trust.test.ts +++ b/tests/autostart-checkout-trust.test.ts @@ -92,6 +92,36 @@ test("private owned checkout paths remain trusted across owner Git updates", asy ).not.toThrow(); }); +test("writable startup inputs and symlink targets are refused despite protected directories", async () => { + for (const name of [ + "hack.config.json", + "docker-compose.yml", + "hack.env.default.yaml", + ".env", + ]) { + const input = join(projectDir, name); + await writeFile(input, "untrusted input", { mode: 0o600 }); + await chmod(input, 0o666); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).toThrow(); + await chmod(input, 0o600); + } + const config = join(projectDir, "hack.config.json"); + const target = join(root, "shared-config.json"); + await writeFile(target, "{}", { mode: 0o600 }); + await chmod(target, 0o666); + await rm(config); + await symlink(target, config); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).toThrow(); + await chmod(target, 0o600); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).not.toThrow(); +}); + test("a real checkout replacement beneath a writable parent is refused without changing permissions", async () => { const parent = join(root, "projects"); await chmod(parent, 0o770); @@ -202,7 +232,10 @@ test("a final pre-start recheck observes checkout trust changes after initial ad expect(startRan).toBe(false); }); -test("source login CLI never runs a checkout hook or engine command under a writable parent", async () => { +test.each([ + "directory", + "config", +])("source login CLI refuses a writable %s before hooks or engine commands", async (kind) => { const hook = join(root, "hook-ran"); const engine = join(root, "engine-ran"); const bin = join(root, "bin"); @@ -219,7 +252,11 @@ test("source login CLI never runs a checkout hook or engine command under a writ `#!${process.execPath}\nawait Bun.write(${JSON.stringify(engine)}, "ran");\nprocess.exit(99);\n`, { mode: 0o700 } ); - await chmod(join(root, "projects"), 0o770); + const unsafe = + kind === "directory" + ? join(root, "projects") + : join(projectDir, "hack.config.json"); + await chmod(unsafe, 0o770); const child = Bun.spawn( [ process.execPath, @@ -256,5 +293,5 @@ test("source login CLI never runs a checkout hook or engine command under a writ expect(stdout + stderr).toContain("checkout_untrusted"); expect(await Bun.file(hook).exists()).toBe(false); expect(await Bun.file(engine).exists()).toBe(false); - expect((await stat(join(root, "projects"))).mode & 0o777).toBe(0o770); + expect((await stat(unsafe)).mode & 0o777).toBe(0o770); }); diff --git a/tests/autostart-docker-trust.test.ts b/tests/autostart-docker-trust.test.ts new file mode 100644 index 000000000..f6fb4e74f --- /dev/null +++ b/tests/autostart-docker-trust.test.ts @@ -0,0 +1,46 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { assertAutostartDockerConfigTrusted } from "../src/lib/autostart-docker-trust.ts"; + +let home = ""; +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "hack-docker-trust-")); + await mkdir(join(home, ".docker"), { mode: 0o700 }); +}); +afterEach(async () => { + await rm(home, { recursive: true, force: true }); +}); + +test("missing or owner-controlled Docker engine selection is accepted without parsing values", async () => { + expect(() => assertAutostartDockerConfigTrusted({ home })).not.toThrow(); + await writeFile(join(home, ".docker/config.json"), "not JSON", { + mode: 0o600, + }); + expect(() => assertAutostartDockerConfigTrusted({ home })).not.toThrow(); +}); + +test("writable Docker config or context inputs refuse before contacting an engine", async () => { + const context = join(home, ".docker/contexts/meta/example"); + await mkdir(context, { recursive: true, mode: 0o700 }); + const meta = join(context, "meta.json"); + await writeFile(meta, "{}", { mode: 0o600 }); + for (const path of [join(home, ".docker"), meta]) { + await chmod(path, 0o777); + expect(() => assertAutostartDockerConfigTrusted({ home })).toThrow(); + await chmod(path, path === meta ? 0o600 : 0o700); + } + const target = join(home, "untrusted-config"); + await writeFile(target, "{}", { mode: 0o600 }); + await chmod(target, 0o666); + await symlink(target, join(home, ".docker/config.json")); + expect(() => assertAutostartDockerConfigTrusted({ home })).toThrow(); +}); diff --git a/tests/autostart-executable-trust.test.ts b/tests/autostart-executable-trust.test.ts index f7719a727..d6c48af65 100644 --- a/tests/autostart-executable-trust.test.ts +++ b/tests/autostart-executable-trust.test.ts @@ -10,7 +10,10 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { assertAutostartExecutablePathTrusted } from "../src/lib/autostart-executable-trust.ts"; +import { + assertAutostartExecutablePathTrusted, + trustedAutostartServicePath, +} from "../src/lib/autostart-executable-trust.ts"; let root = ""; let bin = ""; @@ -33,6 +36,20 @@ test("safe owned lookup directories and programs are accepted without execution" expect(await readFile(program, "utf8")).toBe("#!/bin/sh\nexit 0\n"); }); +test("a group-writable optional tool directory is omitted without blocking the service", async () => { + const homebrew = join(root, "homebrew"); + await mkdir(homebrew, { mode: 0o775 }); + await chmod(homebrew, 0o775); + await writeFile(join(homebrew, "docker"), "#!/bin/sh\nexit 99\n", { + mode: 0o755, + }); + expect( + await trustedAutostartServicePath({ path: `${homebrew}:${bin}` }) + ).toBe(bin); + expect(await trustedAutostartServicePath({ path: homebrew })).toBe(""); + expect((await Bun.file(homebrew).stat()).mode & 0o777).toBe(0o775); +}); + test("writable lookup directories and ancestor paths refuse without permission repair", async () => { await chmod(bin, 0o777); await expect( diff --git a/tests/autostart-routing-assets.test.ts b/tests/autostart-routing-assets.test.ts index 698a3b809..945855e13 100644 --- a/tests/autostart-routing-assets.test.ts +++ b/tests/autostart-routing-assets.test.ts @@ -23,6 +23,7 @@ import { GLOBAL_COREDNS_FILENAME, GLOBAL_ENV_SCHEMA_FILENAME, GLOBAL_GRAFANA_DASHBOARD_FILENAME, + GLOBAL_GRAFANA_DASHBOARDS_DIR, GLOBAL_GRAFANA_DASHBOARDS_PROVISIONING_FILENAME, GLOBAL_GRAFANA_DATASOURCE_FILENAME, GLOBAL_LOGGING_COMPOSE_FILENAME, @@ -203,7 +204,7 @@ test("source global up refuses poisoned login assets before any Docker effect", } }); -test("canonical installed assets are preserved byte-for-byte on fresh private inodes", async () => { +test("canonical nonsecret assets use fresh read-only-to-others inodes readable by container service users", async () => { const previous = new Map(); for (const path of assets.keys()) { previous.set(path, (await lstat(path)).ino); @@ -211,11 +212,85 @@ test("canonical installed assets are preserved byte-for-byte on fresh private in await secure(); for (const [path, text] of assets) { expect(await readFile(path, "utf8")).toBe(text); - expect((await lstat(path)).mode & 0o777).toBe(0o600); + expect((await lstat(path)).mode & 0o777).toBe(0o644); expect((await lstat(path)).ino).not.toBe(previous.get(path)); } }); +test("publication stages outside mounted directories and preserves their exact entries throughout", async () => { + const mounted = [ + join(root, GLOBAL_SCHEMAS_DIR_NAME), + join(root, GLOBAL_LOGGING_DIR_NAME, GLOBAL_GRAFANA_DASHBOARDS_DIR), + ]; + const expected = await Promise.all( + mounted.map(async (path) => (await readdir(path)).sort()) + ); + const staged: string[] = []; + await secureAutostartRoutingAssets({ + root, + bindAddress, + defaultDomain, + beforePublish: async ({ stagingPath, targetPath }) => { + staged.push(stagingPath); + expect(dirname(stagingPath)).toBe(root); + expect((await lstat(stagingPath)).dev).toBe( + (await lstat(targetPath)).dev + ); + for (const [index, path] of mounted.entries()) { + const expectedNames = expected[index]; + if (!expectedNames) { + throw new Error("Missing mounted-directory expectation"); + } + expect((await readdir(path)).sort()).toEqual(expectedNames); + } + }, + }); + expect(staged).toHaveLength(assets.size); + for (const path of staged) { + expect(await Bun.file(path).exists()).toBe(false); + } +}); + +test("SIGKILL during schema staging leaves only an ignored root file and the next login can authenticate", async () => { + const schemas = join(root, GLOBAL_SCHEMAS_DIR_NAME); + const dashboards = join( + root, + GLOBAL_LOGGING_DIR_NAME, + GLOBAL_GRAFANA_DASHBOARDS_DIR + ); + const schemaEntries = (await readdir(schemas)).sort(); + const dashboardEntries = (await readdir(dashboards)).sort(); + const marker = join(root, "staging-interruption-observed"); + const script = `import { secureAutostartRoutingAssets } from ${JSON.stringify(resolve("src/lib/autostart-routing-assets.ts"))};\nawait secureAutostartRoutingAssets({root:${JSON.stringify(root)},bindAddress:${JSON.stringify(bindAddress)},defaultDomain:${JSON.stringify(defaultDomain)},beforePublish:async({stagingPath,targetPath})=>{if(targetPath===${JSON.stringify(join(schemas, GLOBAL_CONFIG_SCHEMA_FILENAME))}){await Bun.write(${JSON.stringify(marker)},stagingPath);process.kill(process.pid,"SIGKILL");}}});`; + const child = Bun.spawn([process.execPath, "--no-env-file", "-e", script], { + env: privateCliEnv({ root }), + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 5000); + try { + const [code, stderr] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + expect(code, stderr).toBe(137); + expect(child.signalCode).toBe("SIGKILL"); + } finally { + clearTimeout(timer); + } + const staging = await Bun.file(marker).text(); + expect(dirname(staging)).toBe(root); + expect(await Bun.file(staging).exists()).toBe(true); + const stagedBytes = await Bun.file(staging).text(); + expect((await readdir(schemas)).sort()).toEqual(schemaEntries); + expect((await readdir(dashboards)).sort()).toEqual(dashboardEntries); + await secure(); + expect(await Bun.file(staging).text()).toBe(stagedBytes); + expect((await readdir(schemas)).sort()).toEqual(schemaEntries); + expect((await readdir(dashboards)).sort()).toEqual(dashboardEntries); +}); + test("current renderer static address variants and IPv6 bind settings are authenticated", async () => { for (const useStaticCoreDnsIp of [false, true]) { for (const useStaticCaddyIp of [false, true]) { diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index acbce5a00..87b062ad4 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -10,6 +10,7 @@ import { autostartServiceEnvironment, autostartServicePath, buildAutostartProjectInvocation, + canAdoptAutostartEnvironment, classifyAutostartContainers, classifyAutostartExit, completedDependencyServices, @@ -1150,3 +1151,123 @@ test("a refused executable path never overwrites an earlier real-boot claim or p (await readProjectsRegistry({ registryPath })).projects[0]?.autostart ).toEqual(before); }); + +test("repairing an executable path permits a first attempt in the same boot", async () => { + await seed(["alpha"]); + deps = { + ...deps, + startupRefusal: async () => "executable_path_untrusted" as const, + }; + await run(); + expect(starts).toEqual([]); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ?.lastAttemptBootId + ).toBeNull(); + deps = { ...deps, startupRefusal: async () => null }; + await run(); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); +}); + +test("unsafe Docker context selection refuses before engine access without consuming a claim", async () => { + await seed(["alpha"]); + await mkdir(join(root, ".docker"), { mode: 0o700 }); + const config = join(root, ".docker/config.json"); + await writeFile(config, JSON.stringify({ currentContext: "untrusted" }), { + mode: 0o600, + }); + await chmod(config, 0o666); + const production = createAutostartProductionDependencies( + { home: root, hackHome: join(root, "state") }, + { executablePath: async () => {} } + ); + deps = { ...deps, startupRefusal: production.startupRefusal }; + await run(); + expect(events).toEqual([]); + expect((await results())[0]?.result).toMatchObject({ + reason: "docker_config_untrusted", + }); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ?.lastAttemptBootId + ).toBeNull(); + await chmod(config, 0o600); + await run(); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); +}); + +test("managed global graph variables decline base-graph adoption without resolving their values", async () => { + await seed(["alpha"]); + const project = (await readProjectsRegistry({ registryPath })).projects[0]; + if (!project) { + throw new Error("Missing fixture project"); + } + await mkdir(project.projectDir, { recursive: true, mode: 0o700 }); + const compose = { services: { web: {}, worker: { profiles: ["jobs"] } } }; + const model = autostartComposeModel(compose); + expect( + classifyAutostartContainers( + "web|running|0", + model?.completedServices, + model?.requiredServices + ) + ).toBe("running"); + expect( + await canAdoptAutostartEnvironment({ + projectRoot: project.repoRoot, + projectDir: project.projectDir, + }) + ).toBe(true); + await writeFile( + join(project.projectDir, "hack.env.default.yaml"), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { + global: { COMPOSE_PROFILES: "jobs", COMPOSE_FILE: "selected.yml" }, + }, + }), + { mode: 0o600 } + ); + expect( + await canAdoptAutostartEnvironment({ + projectRoot: project.repoRoot, + projectDir: project.projectDir, + }) + ).toBe(false); + const production = createAutostartProductionDependencies({ + home: root, + hackHome: join(root, "state"), + }); + expect(await production.inspect(project)).toBe("stopped"); +}); + +test("encrypted contracts, machine overlays and dotenv graph inputs conservatively disable adoption", async () => { + const projectRoot = join(root, "checkout"); + const projectDir = join(projectRoot, ".hack"); + await mkdir(projectDir, { recursive: true, mode: 0o700 }); + for (const [directory, name] of [ + [projectDir, "hack.env.json"], + [projectDir, "hack.env.local.yaml"], + [projectDir, ".env.preview"], + [projectRoot, ".env"], + ]) { + if (!(directory && name)) { + throw new Error("Missing fixture path"); + } + const path = join(directory, name); + await writeFile(path, "synthetic encrypted reference, not decrypted"); + expect( + await canAdoptAutostartEnvironment({ projectRoot, projectDir }) + ).toBe(false); + await rm(path); + } + expect(await canAdoptAutostartEnvironment({ projectRoot, projectDir })).toBe( + true + ); + await rm(projectDir, { recursive: true }); + expect(await canAdoptAutostartEnvironment({ projectRoot, projectDir })).toBe( + false + ); +}); diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md index 4a34b9244..f283e3aca 100644 --- a/tests/models/tla/autostart-start-stop/COVERAGE.md +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -41,6 +41,14 @@ PID identity or lock reclamation, real engine requests, descendant-process clean or any late runtime effect surviving termination of the lock-owning CLI child. In particular, `ChildCrashOrFailure` revokes all remaining child effects; that is a failure assumption, not proof that an orphaned engine request cannot finish later. +`runtime-backend.ts` now forwards cancellation to each owned detached Compose +phase, and `shell.ts` waits for an owned-group census before cancellation returns. +`tests/runtime-backend-cancellation.test.ts` exercises real task-owned phase +descendants that ignore TERM; it maps this process boundary to `ChildSettle`. +The outer login deadline allows five seconds for the inner two-second escalation +and bounded settlement. Killable processes and a functioning process census are +assumptions; SIGKILL of the CLI, host failure, and an engine request already +accepted outside its client group remain outside this finite model. Multiple projects, multiple login attempts, reboot, upgrades, global pause, retries, network prompts, native backends and real login-service behavior are outside this model. Component model success does not qualify installed binaries or real login. diff --git a/tests/runtime-backend-cancellation.test.ts b/tests/runtime-backend-cancellation.test.ts new file mode 100644 index 000000000..6384f22d6 --- /dev/null +++ b/tests/runtime-backend-cancellation.test.ts @@ -0,0 +1,171 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { withAutostartProjectLock } from "../src/lib/autostart-project-lock.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const roots: string[] = []; +const drivers: ReturnType[] = []; +const ownedPhases: { readonly pid: number; readonly identity: string }[] = []; +async function identity(pid: number): Promise { + const child = Bun.spawn( + ["/bin/ps", "-o", "pgid=,lstart=", "-p", String(pid)], + { + env: { PATH: "/usr/bin:/bin", LC_ALL: "C", TZ: "UTC0" }, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + } + ); + const output = (await new Response(child.stdout).text()).trim(); + return (await child.exited) === 0 && output ? output : null; +} +afterEach(async () => { + for (const child of drivers.splice(0)) { + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } + for (const phase of ownedPhases.splice(0)) { + if ( + (await identity(phase.pid)) === phase.identity && + (await live(phase.pid)) + ) { + process.kill(phase.pid, "SIGKILL"); + const deadline = Date.now() + 1000; + while ((await live(phase.pid)) && Date.now() < deadline) { + await Bun.sleep(10); + } + if (await live(phase.pid)) { + throw new Error("Owned phase cleanup unconfirmed; fixture retained."); + } + } + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); +async function live(pid: number): Promise { + const child = Bun.spawn(["/bin/ps", "-o", "stat=", "-p", String(pid)], { + env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); + const state = (await new Response(child.stdout).text()).trim(); + await child.exited; + return state.length > 0 && !state.startsWith("Z"); +} +async function waitReady(path: string, child: ReturnType) { + const deadline = Date.now() + 5000; + while ( + !(await Bun.file(path).exists()) && + Date.now() < deadline && + child.exitCode === null + ) { + await Bun.sleep(10); + } + expect(await Bun.file(path).exists()).toBe(true); +} +for (const phase of ["images", "start"]) { + test(`cancelled detached Compose ${phase} settles resistant descendants before releasing the startup mutex`, async () => { + const root = await mkdtemp(join(tmpdir(), "hack-compose-cancel-")); + roots.push(root); + const bin = join(root, "bin"); + await mkdir(bin); + const ready = join(root, "ready.json"); + const late = join(root, "late-publication"); + const receipt = join(root, "receipt.json"); + const beforeStart = join(root, "before-start"); + const descendantScript = `process.on("SIGTERM",()=>{});process.on("SIGINT",()=>{});await Bun.write(${JSON.stringify(join(root, "descendant-ready"))},String(process.pid));setTimeout(()=>void Bun.write(${JSON.stringify(late)},"must not publish"),3500);setInterval(()=>{},1000);`; + await writeFile( + join(bin, "docker"), + `#!${process.execPath} +const selected=process.argv.includes("--no-start")?"images":"start"; +if(selected!==${JSON.stringify(phase)}) process.exit(0); +process.on("SIGTERM",()=>{ ${phase === "start" ? "process.exit(0);" : "/* resistant leader */"} }); +process.on("SIGINT",()=>{}); +const child=Bun.spawn([process.execPath,"-e",${JSON.stringify(descendantScript)}],{stdin:"ignore",stdout:"ignore",stderr:"ignore"}); +const deadline=Date.now()+3000;while(!(await Bun.file(${JSON.stringify(join(root, "descendant-ready"))}).exists())){if(Date.now()>deadline)process.exit(98);await Bun.sleep(10);} +await Bun.write(${JSON.stringify(ready)},JSON.stringify({leader:process.pid,descendant:child.pid})); +setInterval(()=>{},1000); +`, + { mode: 0o755 } + ); + const registryPath = join(root, "home", ".hack", "projects.json"); + const scope = { + projectId: "example-id", + composeProject: "example", + branch: null, + }; + const driver = join(root, "driver.ts"); + await writeFile( + driver, + ` +import {composeRuntimeBackend} from ${JSON.stringify(resolve("src/backends/runtime-backend.ts"))}; +import {withAutostartProjectLock} from ${JSON.stringify(resolve("src/lib/autostart-project-lock.ts"))}; +const code=await withAutostartProjectLock({registryPath:${JSON.stringify(registryPath)},scope:${JSON.stringify(scope)},run:()=>composeRuntimeBackend.up({composeFiles:["compose.yml"],composeProject:"example",cwd:${JSON.stringify(root)},env:{PATH:${JSON.stringify(bin)}},detach:true,imageTimeoutMs:10000,startupTimeoutMs:10000,beforeStart:async()=>{await Bun.write(${JSON.stringify(beforeStart)},"entered");}})}); +await Bun.write(${JSON.stringify(receipt)},JSON.stringify({code})); +process.exitCode=code===143?0:1; +` + ); + const env = privateCliEnv({ + root, + home: join(root, "home"), + env: { PATH: bin }, + }); + await mkdir(join(root, "home", ".hack"), { recursive: true }); + const child = Bun.spawn([process.execPath, driver], { + cwd: root, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + drivers.push(child); + const output = Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + await waitReady(ready, child); + const pids: { leader: number; descendant: number } = + await Bun.file(ready).json(); + for (const pid of [pids.leader, pids.descendant]) { + const proof = await identity(pid); + if (proof) { + ownedPhases.push({ pid, identity: proof }); + } + } + let observedAtRelease: + | { leader: boolean; descendant: boolean; late: boolean } + | undefined; + const queued = withAutostartProjectLock({ + registryPath, + scope, + run: async () => { + observedAtRelease = { + leader: await live(pids.leader), + descendant: await live(pids.descendant), + late: await Bun.file(late).exists(), + }; + }, + }); + const started = Date.now(); + child.kill("SIGTERM"); + const [stdout, stderr, code] = await output; + expect(code, stdout + stderr).toBe(0); + expect(Date.now() - started).toBeLessThan(4500); + await queued; + expect(observedAtRelease).toEqual({ + leader: false, + descendant: false, + late: false, + }); + expect(await Bun.file(receipt).json()).toEqual({ code: 143 }); + expect(await Bun.file(beforeStart).exists()).toBe(phase === "start"); + }, 10_000); +} diff --git a/tests/runtime-backend.test.ts b/tests/runtime-backend.test.ts index 5106dc713..b313010ac 100644 --- a/tests/runtime-backend.test.ts +++ b/tests/runtime-backend.test.ts @@ -10,6 +10,7 @@ const runOpts: { stdout?: string; timeoutMs?: number; forwardSignals?: boolean; + settleProcessGroupOnCancel?: boolean; }[] = []; const execCalls: string[][] = []; @@ -31,6 +32,7 @@ const shellMock = await registerScopedModuleMock({ readonly stdout?: string; readonly timeoutMs?: number; readonly forwardSignals?: boolean; + readonly settleProcessGroupOnCancel?: boolean; } = {} ) => { runCalls.push([...cmd]); @@ -38,6 +40,7 @@ const shellMock = await registerScopedModuleMock({ stdout: opts.stdout, timeoutMs: opts.timeoutMs, forwardSignals: opts.forwardSignals, + settleProcessGroupOnCancel: opts.settleProcessGroupOnCancel, }); return 0; }, @@ -106,6 +109,14 @@ test("composeRuntimeBackend.up builds compose args with profiles and detach", as "--profile", "ops", ]; + expect(runOpts.map((options) => options.forwardSignals)).toEqual([ + true, + true, + ]); + expect(runOpts.map((options) => options.settleProcessGroupOnCancel)).toEqual([ + true, + true, + ]); // Image build/pull and container creation, then start and dependency wait. expect(runCalls).toEqual([ [...base, "up", "--no-start"], @@ -479,5 +490,6 @@ test("automatic bootstrap preserves output and forwards cancellation to its boun stdout: "stderr", timeoutMs: 600_000, forwardSignals: true, + settleProcessGroupOnCancel: undefined, }); }); diff --git a/tests/shell-abort.test.ts b/tests/shell-abort.test.ts index d2c135df4..4d37706d6 100644 --- a/tests/shell-abort.test.ts +++ b/tests/shell-abort.test.ts @@ -6,6 +6,15 @@ import { join, resolve } from "node:path"; import { type RunExitEvent, type RunOptions, run } from "../src/lib/shell.ts"; import { runWithTerminalGroup } from "../src/lib/tty-run.ts"; +import { privateCliRunEnv } from "./helpers/private-cli-env.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +const unknownSettlement = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "../src/lib/process-group-settlement.ts", + overrides: { waitForOwnedProcessGroupSettlement: async () => false }, +}); + const roots: string[] = []; const groups: number[] = []; afterEach(async () => { @@ -287,3 +296,33 @@ function processAlive(pid: number): boolean { return false; } } + +test("required group settlement refuses an unknown census before recording a cancelled result", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-shell-settlement-")); + roots.push(root); + const controller = new AbortController(); + let exitObservations = 0; + unknownSettlement.activate(); + try { + await expect( + run([process.execPath, "-e", "setInterval(()=>{},1000)"], { + cwd: root, + ...privateCliRunEnv({ root }), + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + signal: controller.signal, + settleProcessGroupOnCancel: true, + onSpawn: async () => { + controller.abort(); + }, + onExit: async () => { + exitObservations += 1; + }, + }) + ).rejects.toThrow("group settlement could not be confirmed"); + expect(exitObservations).toBe(0); + } finally { + unknownSettlement.deactivate(); + } +}); From 85751156f9b420bff56c931febb36a874ea632b0 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:22:09 +0000 Subject: [PATCH 15/44] fix(autostart): isolate strict trust checks and explain prerequisites --- docs/always-on-projects.md | 26 ++++++++-- docs/versions/v5/getting-started.md | 4 +- src/commands/doctor.ts | 13 +++++ src/commands/global-autostart.ts | 5 +- src/daemon/server.ts | 6 --- src/lib/autostart-checkout-trust.ts | 60 ++++++++++++++++++++++- src/lib/autostart-docker-trust.ts | 36 +++++++++++++- src/lib/autostart-executable-trust.ts | 16 ------ src/lib/autostart-trust-diagnostics.ts | 39 +++++++++++++++ tests/autostart-checkout-trust.test.ts | 17 +++++++ tests/autostart-docker-trust.test.ts | 24 ++++++++- tests/autostart-executable-trust.test.ts | 14 ++---- tests/autostart-trust-diagnostics.test.ts | 50 +++++++++++++++++++ 13 files changed, 266 insertions(+), 44 deletions(-) create mode 100644 src/lib/autostart-trust-diagnostics.ts create mode 100644 tests/autostart-trust-diagnostics.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index e1c156863..460b28d3d 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -69,15 +69,29 @@ Children receive a fixed environment allow-list and an executable search path built from system directories and known Bun, Hack and Docker directories under the owned home (plus Linux `.local/bin`); launching-shell `DOCKER_*`, `COMPOSE_*` and `HACK_*` overrides are not copied. Hack adds only its explicit internal noninteractive and identity settings. -Every login-service launch removes unsafe directories from its executable lookup -path before starting an engine watcher; it keeps serving the local API when an -optional tool directory is unsafe. Explicit passes check the derived path before helper +Only automatic passes check the derived executable path before helper lookup too. Directories, executable files and symlink targets must satisfy the same read-only ownership and permission checks used by the Linux service; unsafe paths are refused without automatic permission repair. This strict policy also applies to group-writable Homebrew directories, including group `admin`: install required helpers in a trusted lookup directory before retrying. A refused search path does not consume a project's attempt for this boot. +The daemon keeps its installed PATH and continues normal status observation; +automatic-start refusal does not change ordinary daemon operation. +The strict policy also applies to Linux user-private groups. Check `hack doctor` +for the affected trust category. After reviewing ownership and who belongs to the +group, remove group/other write only on the intended files and directories with +`chmod go-w `; do not recursively change a shared checkout or +Homebrew installation. Using a private checkout and private tool installation is +an alternative. Hack never changes those permissions automatically. +Use `umask 022` in shells that run `hack up` so changed generated inputs retain +safe modes; with umask 002, a later manual start can recreate group-writable +generated files and require another review before automatic startup. +This restriction can also affect Docker Desktop and OrbStack: their CLI and +Compose plugin links can point into app bundles beneath group-writable +`/Applications`. Such installations are refused by the automatic path even when +manual Docker works. Do not change `/Applications` permissions as an automatic +repair; use reviewed private tools or keep starting projects manually. Already-running instances are adopted without repeating startup hooks. An unknown runtime state is skipped rather than taken as proof that the instance is down. @@ -115,15 +129,19 @@ current user or root and cannot be replaced by other users. The check repeats before container startup. A shared writable parent is refused even when the saved registry pathname still matches. Startup configuration, Compose and environment input files and their symlink targets also require trusted ownership and modes; +generated JSON/YAML/env inputs under `.hack/.internal` and `.hack/.branch` are included. protected directories alone do not make a writable lifecycle configuration safe. Normal owner Git updates remain allowed. This is not a recursive audit of all repository code a hook or build may execute. Docker's `config.json` and context metadata/TLS paths must likewise be owned by the current user or root and not writable by other users. These checks inspect -filesystem metadata without reading credentials, and repeat in login children +filesystem metadata without exposing credentials, and repeat in login children before runtime startup. Unsafe engine selection refuses without consuming a boot attempt; owner-controlled context changes remain supported. +Docker Compose plugin directories and their executables receive the same checks. +A Docker config with `cliPluginsExtraDirs` requires manual startup; automatic +startup parses only this policy setting and never reports credential fields. No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 43af15846..4e81040fb 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -66,7 +66,9 @@ hack open To start this project at future logins, use `hack up -d --always` and install the per-user daemon with `hack daemon install`. `hack down` pauses its marker; `hack up` resumes it. See [Always-on projects](https://github.com/hack-dance/hack/blob/next/docs/always-on-projects.md) for -the startup bounds, no-prompt behavior and JSON contract. +the startup bounds, no-prompt behavior and JSON contract. Automatic startup requires +trusted, non-writable-by-others tool and project paths; `hack doctor` reports +permission refusals, including app-bundle and Homebrew paths on macOS. Expect the declared services in `ps` and the app's local URL from `open`. If a project has several URLs, choose the app's route. A worker-only project may diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 79a92050b..af3e93e63 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,4 +1,5 @@ import { lookup } from "node:dns/promises"; +import { homedir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { note, spinner } from "@clack/prompts"; import { YAML } from "bun"; @@ -66,6 +67,7 @@ import { resolveSystemdUnitPath, SYSTEMD_READ_ONLY_ENV, } from "../daemon/systemd.ts"; +import { autostartTrustWarnings } from "../lib/autostart-trust-diagnostics.ts"; import { type CaTrustReport, inspectCaTrust, @@ -1981,6 +1983,17 @@ async function checkAutostartLoginService(): Promise { message: "No always-on projects marked.", }; } + const warnings = await autostartTrustWarnings({ + projects: registry.projects, + home: homedir(), + }); + if (warnings.length > 0) { + return { + name: "autostart login service", + status: "warn", + message: `Automatic startup trust prerequisites are not met: ${warnings.join(", ")}. Review ownership and group/other write permissions; see docs/always-on-projects.md. On macOS this can include Homebrew, Docker Desktop or OrbStack links through /Applications. Ordinary daemon operation is unaffected.`, + }; + } let unitPath: string | null = null; if (isMac()) { unitPath = resolveDaemonPaths({}).launchdPlistPath; diff --git a/src/commands/global-autostart.ts b/src/commands/global-autostart.ts index e3bbf263a..4f0c63326 100644 --- a/src/commands/global-autostart.ts +++ b/src/commands/global-autostart.ts @@ -1,3 +1,5 @@ +import { homedir } from "node:os"; +import { assertAutostartDockerConfigTrusted } from "../lib/autostart-docker-trust.ts"; import { secureAutostartRoutingAssets } from "../lib/autostart-routing-assets.ts"; import { HackCliError } from "../lib/cli-result.ts"; import { resolveDefaultDomain } from "../lib/default-domain.ts"; @@ -66,6 +68,3 @@ export async function reconcileGlobalRoutingBridge(opts: { }); } } - -import { homedir } from "node:os"; -import { assertAutostartDockerConfigTrusted } from "../lib/autostart-docker-trust.ts"; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index b2d3320d1..b6a8050bf 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -27,7 +27,6 @@ import { type ControlPlaneConfig, readControlPlaneConfig, } from "../control-plane/sdk/config.ts"; -import { trustedAutostartServicePath } from "../lib/autostart-executable-trust.ts"; import { ensureDir } from "../lib/fs.ts"; import { resolveRegisteredProjectById } from "../lib/projects-registry.ts"; import { runAutostartAtLogin } from "./autostart.ts"; @@ -97,11 +96,6 @@ export async function runDaemon({ await ensureDir(paths.root); // Before any socket is bound: the local API is unauthenticated. await secureDaemonDirectory({ root: paths.root }); - if (process.env.HACK_DAEMON_LOGIN_SERVICE === "1") { - process.env.PATH = await trustedAutostartServicePath({ - path: process.env.PATH ?? "", - }); - } await removeFileIfExists({ path: paths.socketPath }); await writeDaemonPid({ pidPath: paths.pidPath, pid: process.pid }); diff --git a/src/lib/autostart-checkout-trust.ts b/src/lib/autostart-checkout-trust.ts index 50e5d8152..a210cc174 100644 --- a/src/lib/autostart-checkout-trust.ts +++ b/src/lib/autostart-checkout-trust.ts @@ -1,10 +1,11 @@ -import { readdirSync, statSync } from "node:fs"; +import { readdirSync, realpathSync, statSync } from "node:fs"; import { join } from "node:path"; import { HackCliError } from "./cli-result.ts"; import { checkReplaceableChain, walkReplaceableChain } from "./path-trust.ts"; const STARTUP_INPUT = /^(?:hack\.(?:config|env)(?:\..*)?\.(?:json|ya?ml)|docker-compose(?:\..*)?\.ya?ml|\.env(?:\..*)?)$/; +const GENERATED_INPUT = /\.(?:json|ya?ml)$|^\.env/; /** * Automatic startup may execute checkout hooks only through paths another user @@ -58,6 +59,63 @@ export function assertAutostartCheckoutTrusted(opts: { throw refusal(); } } + const visited = new Set(); + let entries = 0; + const generatedDirectoryExists = (path: string): boolean => { + const result = walkReplaceableChain({ + path, + uid, + hackHome: null, + checked, + allowMissing: true, + }); + if ("error" in result) { + throw refusal(); + } + if (!result.complete) { + return false; + } + if (!statSync(path).isDirectory()) { + throw refusal(); + } + const physical = realpathSync(path); + if (visited.has(physical)) { + throw refusal(); + } + visited.add(physical); + return true; + }; + const checkGenerated = (path: string, depth: number): void => { + entries += 1; + if (entries > 4096 || depth > 16) { + throw refusal(); + } + if (!generatedDirectoryExists(path)) { + return; + } + for (const entry of readdirSync(path, { withFileTypes: true })) { + const input = join(path, entry.name); + if ( + entry.isDirectory() || + (entry.isSymbolicLink() && statSync(input).isDirectory()) + ) { + checkGenerated(input, depth + 1); + } else if ( + GENERATED_INPUT.test(entry.name) && + (checkReplaceableChain({ + path: input, + uid, + hackHome: null, + checked, + }) !== null || + !statSync(input).isFile()) + ) { + throw refusal(); + } + } + }; + checkGenerated(join(opts.projectDir, ".internal"), 0); + checkGenerated(join(opts.projectDir, ".branch"), 0); } catch { throw refusal(); } diff --git a/src/lib/autostart-docker-trust.ts b/src/lib/autostart-docker-trust.ts index fc0340ffd..5724e2957 100644 --- a/src/lib/autostart-docker-trust.ts +++ b/src/lib/autostart-docker-trust.ts @@ -1,11 +1,13 @@ -import { readdirSync, realpathSync, statSync } from "node:fs"; +import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { join } from "node:path"; import { HackCliError } from "./cli-result.ts"; +import { isRecord } from "./guards.ts"; import { walkReplaceableChain } from "./path-trust.ts"; -/** Authenticate Docker's engine-selection inputs without reading credentials. */ +/** Authenticate Docker's engine-selection inputs without exposing credentials. */ export function assertAutostartDockerConfigTrusted(opts: { readonly home: string; + readonly systemPluginDirectories?: readonly string[]; }): void { try { const uid = process.getuid?.(); @@ -54,6 +56,36 @@ export function assertAutostartDockerConfigTrusted(opts: { // caches do not select an engine and can be arbitrarily large. visit(join(root, "config.json"), 0); visit(join(root, "contexts"), 0); + visit(join(root, "cli-plugins"), 0); + for (const directory of opts.systemPluginDirectories ?? [ + "/usr/local/lib/docker/cli-plugins", + "/usr/local/libexec/docker/cli-plugins", + "/usr/lib/docker/cli-plugins", + "/usr/libexec/docker/cli-plugins", + ]) { + visit(directory, 0); + } + const configPath = join(root, "config.json"); + const configTrust = walkReplaceableChain({ + path: configPath, + uid, + hackHome: null, + allowMissing: true, + }); + if (!("error" in configTrust) && configTrust.complete) { + if (statSync(configPath).size > 1024 * 1024) { + throw new Error("Docker configuration exceeds bound"); + } + // Credential fields stay inside this parser and never enter diagnostics. + const config: unknown = JSON.parse(readFileSync(configPath, "utf8")); + if (!isRecord(config)) { + throw new Error("Invalid Docker configuration"); + } + if (config.cliPluginsExtraDirs !== undefined) { + // Custom plugin discovery is not part of the fixed login toolchain. + throw new Error("Custom plugin search path requires manual startup"); + } + } } catch { throw new HackCliError({ code: "E_PERMISSION", diff --git a/src/lib/autostart-executable-trust.ts b/src/lib/autostart-executable-trust.ts index be89aa613..c0ff9dc23 100644 --- a/src/lib/autostart-executable-trust.ts +++ b/src/lib/autostart-executable-trust.ts @@ -1,22 +1,6 @@ import { checkServicePathDirectories } from "../daemon/systemd.ts"; import { HackCliError } from "./cli-result.ts"; -/** Omit unsafe service lookup directories without preventing daemon startup. */ -export async function trustedAutostartServicePath(opts: { - readonly path: string; -}): Promise { - const trusted: string[] = []; - for (const directory of opts.path.split(":")) { - try { - await assertAutostartExecutablePathTrusted({ path: directory }); - trusted.push(directory); - } catch { - // A missing or unsafe optional tool directory is not a daemon failure. - } - } - return trusted.join(":"); -} - /** * Login helper lookup has the same read-only ownership and mode requirements on * both platforms. Check every lookup directory and its executable/symlink diff --git a/src/lib/autostart-trust-diagnostics.ts b/src/lib/autostart-trust-diagnostics.ts new file mode 100644 index 000000000..5a0a2853c --- /dev/null +++ b/src/lib/autostart-trust-diagnostics.ts @@ -0,0 +1,39 @@ +import { autostartServicePath } from "../daemon/autostart.ts"; +import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; +import { assertAutostartDockerConfigTrusted } from "./autostart-docker-trust.ts"; +import { assertAutostartExecutablePathTrusted } from "./autostart-executable-trust.ts"; +import type { RegisteredProject } from "./projects-registry.ts"; + +/** File-only doctor observation; never executes helpers or repairs permissions. */ +export async function autostartTrustWarnings(opts: { + readonly projects: readonly RegisteredProject[]; + readonly home: string; + readonly executablePath?: string; +}): Promise { + const warnings = new Set(); + for (const project of opts.projects) { + if (!project.autostart) { + continue; + } + try { + assertAutostartCheckoutTrusted(project); + } catch { + warnings.add("checkout_untrusted"); + } + } + try { + assertAutostartDockerConfigTrusted({ home: opts.home }); + } catch { + warnings.add("docker_config_untrusted"); + } + try { + await assertAutostartExecutablePathTrusted({ + path: + opts.executablePath ?? + autostartServicePath({ home: opts.home, platform: process.platform }), + }); + } catch { + warnings.add("executable_path_untrusted"); + } + return [...warnings]; +} diff --git a/tests/autostart-checkout-trust.test.ts b/tests/autostart-checkout-trust.test.ts index 46dae0485..5dfd52a9c 100644 --- a/tests/autostart-checkout-trust.test.ts +++ b/tests/autostart-checkout-trust.test.ts @@ -122,6 +122,23 @@ test("writable startup inputs and symlink targets are refused despite protected ).not.toThrow(); }); +test("generated internal Compose and branch configuration must also be trusted", async () => { + for (const directory of [".internal", ".branch"]) { + const generated = join(projectDir, directory, "example"); + await mkdir(generated, { recursive: true, mode: 0o700 }); + const file = join(generated, "docker-compose.override.yml"); + await writeFile(file, "services: {}", { mode: 0o600 }); + await chmod(file, 0o666); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).toThrow(); + await chmod(file, 0o600); + expect(() => + assertAutostartCheckoutTrusted({ repoRoot, projectDir }) + ).not.toThrow(); + } +}); + test("a real checkout replacement beneath a writable parent is refused without changing permissions", async () => { const parent = join(root, "projects"); await chmod(parent, 0o770); diff --git a/tests/autostart-docker-trust.test.ts b/tests/autostart-docker-trust.test.ts index f6fb4e74f..6ced755b9 100644 --- a/tests/autostart-docker-trust.test.ts +++ b/tests/autostart-docker-trust.test.ts @@ -20,14 +20,34 @@ afterEach(async () => { await rm(home, { recursive: true, force: true }); }); -test("missing or owner-controlled Docker engine selection is accepted without parsing values", async () => { +test("missing or owner-controlled Docker engine selection is accepted without exposing values", async () => { expect(() => assertAutostartDockerConfigTrusted({ home })).not.toThrow(); - await writeFile(join(home, ".docker/config.json"), "not JSON", { + await writeFile(join(home, ".docker/config.json"), "{}", { mode: 0o600, }); expect(() => assertAutostartDockerConfigTrusted({ home })).not.toThrow(); }); +test("writable Compose plugins and custom plugin search paths refuse automatic startup", async () => { + const plugins = join(home, ".docker/cli-plugins"); + await mkdir(plugins, { mode: 0o700 }); + const plugin = join(plugins, "docker-compose"); + await writeFile(plugin, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + await chmod(plugin, 0o777); + expect(() => + assertAutostartDockerConfigTrusted({ home, systemPluginDirectories: [] }) + ).toThrow(); + await chmod(plugin, 0o700); + await writeFile( + join(home, ".docker/config.json"), + JSON.stringify({ cliPluginsExtraDirs: [plugins] }), + { mode: 0o600 } + ); + expect(() => + assertAutostartDockerConfigTrusted({ home, systemPluginDirectories: [] }) + ).toThrow(); +}); + test("writable Docker config or context inputs refuse before contacting an engine", async () => { const context = join(home, ".docker/contexts/meta/example"); await mkdir(context, { recursive: true, mode: 0o700 }); diff --git a/tests/autostart-executable-trust.test.ts b/tests/autostart-executable-trust.test.ts index d6c48af65..260ec2ad0 100644 --- a/tests/autostart-executable-trust.test.ts +++ b/tests/autostart-executable-trust.test.ts @@ -10,10 +10,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - assertAutostartExecutablePathTrusted, - trustedAutostartServicePath, -} from "../src/lib/autostart-executable-trust.ts"; +import { assertAutostartExecutablePathTrusted } from "../src/lib/autostart-executable-trust.ts"; let root = ""; let bin = ""; @@ -36,17 +33,16 @@ test("safe owned lookup directories and programs are accepted without execution" expect(await readFile(program, "utf8")).toBe("#!/bin/sh\nexit 0\n"); }); -test("a group-writable optional tool directory is omitted without blocking the service", async () => { +test("a group-writable optional tool directory refuses only automatic execution", async () => { const homebrew = join(root, "homebrew"); await mkdir(homebrew, { mode: 0o775 }); await chmod(homebrew, 0o775); await writeFile(join(homebrew, "docker"), "#!/bin/sh\nexit 99\n", { mode: 0o755, }); - expect( - await trustedAutostartServicePath({ path: `${homebrew}:${bin}` }) - ).toBe(bin); - expect(await trustedAutostartServicePath({ path: homebrew })).toBe(""); + await expect( + assertAutostartExecutablePathTrusted({ path: `${homebrew}:${bin}` }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); expect((await Bun.file(homebrew).stat()).mode & 0o777).toBe(0o775); }); diff --git a/tests/autostart-trust-diagnostics.test.ts b/tests/autostart-trust-diagnostics.test.ts new file mode 100644 index 000000000..8804cb418 --- /dev/null +++ b/tests/autostart-trust-diagnostics.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { autostartTrustWarnings } from "../src/lib/autostart-trust-diagnostics.ts"; + +test("doctor explains automatic-start refusal for a real umask-002 checkout without repairing it", async () => { + const home = await mkdtemp(join(tmpdir(), "hack-trust-doctor-")); + try { + const repoRoot = join(home, "project"); + const projectDir = join(repoRoot, ".hack"); + const previous = process.umask(0o002); + try { + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "hack.config.json"), "{}"); + mkdirSync(join(home, ".docker"), { mode: 0o700 }); + writeFileSync(join(home, ".docker/config.json"), "{}"); + mkdirSync(join(home, "bin"), { mode: 0o700 }); + } finally { + process.umask(previous); + } + const warnings = await autostartTrustWarnings({ + home, + executablePath: join(home, "bin"), + projects: [ + { + id: "example", + name: "example", + repoRoot, + projectDir, + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + autostart: { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: null, + }, + }, + ], + }); + expect(warnings).toEqual(["checkout_untrusted", "docker_config_untrusted"]); + expect( + (await Bun.file(join(projectDir, "hack.config.json")).stat()).mode & 0o777 + ).toBe(0o664); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); From 873d4025760667bfc7817847e050de320340e918 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:28:08 +0000 Subject: [PATCH 16/44] fix(cli): preserve detached worktree branch usage guidance --- src/commands/project-autostart-intent.ts | 2 +- src/commands/project.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 39145d926..a19bf1965 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -83,7 +83,7 @@ export async function resolveFinalAutostartDownTarget( }); if (effective.source === "detached-worktree") { throw new CliUsageError( - "Detached linked worktree requires an explicit --branch." + "Detached linked worktree requires an explicit --branch ." ); } if (explicitBranch !== null || effective.branch === null) { diff --git a/src/commands/project.ts b/src/commands/project.ts index 238a508f8..4dde6b336 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -6468,7 +6468,7 @@ async function updateAutostartIntent(opts: { }); if (resolvedBranch.source === "detached-worktree") { throw new CliUsageError( - "Detached linked worktree requires an explicit --branch." + "Detached linked worktree requires an explicit --branch ." ); } const branch = opts.resolvedDownTarget From ac596872b3a6d8642a187dc23509d210c41a219e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:13:49 +0000 Subject: [PATCH 17/44] fix(autostart): restrict unattended starts to prepared Compose projects --- docs/always-on-projects.md | 46 +- docs/versions/v5/getting-started.md | 3 + src/backends/runtime-backend.ts | 125 +++++- src/commands/project-autostart-intent.ts | 8 +- src/commands/project.ts | 15 + src/daemon/autostart.ts | 90 +++- src/lib/autostart-project-lock.ts | 4 + src/lib/autostart-refusal.ts | 32 ++ src/lib/autostart-start-policy.ts | 403 ++++++++++++++++++ src/lib/autostart-trust-diagnostics.ts | 5 + src/lib/child-environment.ts | 18 + src/lib/dependency-cache-bootstrap.ts | 2 + src/lib/project.ts | 6 +- src/lib/projects-registry.ts | 20 +- src/lib/secret-store.ts | 12 + src/lib/shell.ts | 117 +++++ tests/autostart-checkout-trust.test.ts | 26 +- tests/autostart-host-boundary.test.ts | 83 ++++ tests/autostart-start-policy.test.ts | 314 ++++++++++++++ tests/child-environment.test.ts | 48 ++- tests/daemon-autostart.test.ts | 53 +++ .../tla/autostart-start-stop/COVERAGE.md | 8 + tests/project-autostart.test.ts | 185 ++++++++ tests/runtime-backend-unattended.test.ts | 58 +++ tests/runtime-backend.test.ts | 176 +++++++- tests/shell-bounded-capture.test.ts | 126 ++++++ 26 files changed, 1935 insertions(+), 48 deletions(-) create mode 100644 src/lib/autostart-refusal.ts create mode 100644 src/lib/autostart-start-policy.ts create mode 100644 tests/autostart-host-boundary.test.ts create mode 100644 tests/autostart-start-policy.test.ts create mode 100644 tests/runtime-backend-unattended.test.ts create mode 100644 tests/shell-bounded-capture.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 460b28d3d..7c2b48183 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -92,7 +92,23 @@ Compose plugin links can point into app bundles beneath group-writable `/Applications`. Such installations are refused by the automatic path even when manual Docker works. Do not change `/Applications` permissions as an automatic repair; use reviewed private tools or keep starting projects manually. -Already-running instances are adopted without repeating startup hooks. An unknown +This unit supports Compose-only unattended starts. Projects declaring startup or +lifecycle hooks, host processes, shared helpers or dependency bootstrap require +manual `hack up`. The login child never executes those commands, even if the +configuration changes after the daemon's first observation. Command/cloud env +providers also require a manual start; built-in local encrypted env layers remain +supported without keychain fallback. Managed values that change Compose selection, +Docker lookup or host process execution are refused without resolving secrets. +Compose `build`, `include`, `extends`, `env_file` and external service-provider +declarations require manual startup in this slice. Images must already be present +locally: both Compose phases use `--no-build --pull never` and disable implicit +dotenv imports. Missing images never trigger a build or pull. The intent/trust +guard runs before preparation as well as before container start. +These manual-start requirements produce a closed skip and consume no boot claim +or failure entry. Doctor reports the reason. Fix the unsupported prerequisite or +start manually; ordinary manual starts retain their behavior. + +Already-running eligible instances are adopted. An unknown runtime state is skipped rather than taken as proof that the instance is down. Automatic routing accepts only the already-installed files produced by this @@ -119,9 +135,10 @@ to have a container, including required dependency targets. A missing service runs the normal `up` path. This slice reads the primary Compose file; branch overrides, declared `scale` or `deploy.replicas`, explicit Compose file/profile overrides and unsupported composition -cannot prove adoption, so they run ordinary `up` and may repeat startup hooks. -Managed environment metadata and dotenv inputs also require ordinary `up`; -adoption never decrypts environment values to guess the selected graph. +cannot prove adoption. Supported ambiguous observations take restricted Compose +`up`; unsupported input composition requires manual startup. Managed environment +metadata also disables adoption; observation never decrypts environment values to +guess the selected graph. Legacy dotenv inputs require manual migration/startup. Before project input discovery and again under the instance start lock, login checks that the checkout and project-directory ancestor chains belong to the @@ -131,8 +148,12 @@ saved registry pathname still matches. Startup configuration, Compose and enviro input files and their symlink targets also require trusted ownership and modes; generated JSON/YAML/env inputs under `.hack/.internal` and `.hack/.branch` are included. protected directories alone do not make a writable lifecycle configuration safe. -Normal owner Git updates remain allowed. This is not a recursive audit of all -repository code a hook or build may execute. +Normal owner Git updates remain allowed. This is not a recursive audit of app +source mounted into containers. Unattended host hooks and image builds are refused +rather than relying on filename parsing to authenticate arbitrary shell code. +Login's Git identity, tracking and ignore probes disable system/global Git +configuration, filesystem-monitor callbacks and hooks. Managed env layers cannot +override those Git settings. Manual Git and manual starts retain their settings. Docker's `config.json` and context metadata/TLS paths must likewise be owned by the current user or root and not writable by other users. These checks inspect @@ -191,10 +212,10 @@ startup. Test harnesses and the isolated `hack-v5` launcher set it; those runs d not start marked projects. Use the installed CLI/service for login startup. This first slice starts projects once. It does not restart a project after failure -or replace terminal-based lifecycle supervision. Persistent host helpers retain -the existing [lifecycle](lifecycle.md) behavior. On Linux, service-origin starts -run in independent transient user scopes, so their host helpers survive daemon -stop/restart while the daemon retains `KillMode=control-group`. Failure to create +or replace terminal-based lifecycle supervision. Host helpers require manual +starts and retain the existing [lifecycle](lifecycle.md) behavior. On Linux, +service-origin starts run in independent transient user scopes, separate from the +daemon's `KillMode=control-group`. Failure to create a scope refuses the start rather than falling back into the daemon cgroup. Linux's systemd user service starts at login; startup before login requires separately opting into user lingering. @@ -280,7 +301,10 @@ Current skip reasons are `engine_timeout`, `routing_failed`, `boot_identity_unavailable`, `native_unsupported`, `runtime_unknown`, `interactive_required`, `env_key_missing`, `keychain_unavailable`, `pending_env_migration`, `project_unavailable`, `checkout_untrusted`, -`executable_path_untrusted`, `docker_config_untrusted`, `paused`, `target_changed`, +`executable_path_untrusted`, `docker_config_untrusted`, +`host_commands_require_manual_start`, `image_build_requires_manual_start`, +`image_requires_manual_start`, `command_provider_requires_manual_start`, +`compose_inputs_require_manual_start`, `paused`, `target_changed`, `daemon_stopped`, `global_paused`, and `invalid_marker`. Failure reasons are `start_failed`, `start_timeout`, and `output_limit`. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 4e81040fb..501797d46 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -69,6 +69,9 @@ the per-user daemon with `hack daemon install`. `hack down` pauses its marker; the startup bounds, no-prompt behavior and JSON contract. Automatic startup requires trusted, non-writable-by-others tool and project paths; `hack doctor` reports permission refusals, including app-bundle and Homebrew paths on macOS. +This slice starts only eligible Compose projects with images already available +locally. Hooks, host helpers, command providers and image builds require manual +`hack up`; doctor explains those manual-start requirements. Expect the declared services in `ps` and the app's local URL from `open`. If a project has several URLs, choose the app's route. A worker-only project may diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index 527f8457f..f50706cb5 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -1,4 +1,5 @@ import { childEnvironment } from "../lib/child-environment.ts"; +import { HackCliError } from "../lib/cli-result.ts"; import { type ComposeStartupPhase, DEFAULT_COMPOSE_STARTUP_TIMEOUT_MS, @@ -37,6 +38,8 @@ export interface RuntimeBaseOptions { } export interface RuntimeUpOptions extends RuntimeBaseOptions { + /** Unattended startup may only reuse local images, never build, pull or load an implicit .env. */ + readonly unattended?: boolean; readonly dependencyProgress?: { readonly project: string; readonly services: readonly string[]; @@ -56,6 +59,7 @@ export interface RuntimeUpOptions extends RuntimeBaseOptions { * (`up --no-start`) and before `up -d`, or before an attached `up`. A throw * rejects `up` without starting anything, so a caller can refuse to launch * services whose prerequisites were withdrawn while images were prepared. + * Unattended starts also invoke it before preparation can create containers. */ readonly beforeStart?: () => Promise; readonly services?: readonly string[]; @@ -150,10 +154,107 @@ export function buildComposeExecCommand(opts: { ]; } +const UNATTENDED_IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@+[\]-]{0,511}$/; +const LOCAL_IMAGE_ID = /^sha256:[a-f0-9]{64}\s*$/; + +/** Only an exact daemon missing-image diagnostic authorizes a neutral manual-start refusal. */ +async function inspectUnattendedImages( + opts: RuntimeUpOptions, + composeArgs: readonly string[], + timeoutMs: number +): Promise { + const deadline = Date.now() + Math.min(timeoutMs, 15_000); + const probe = async (args: readonly string[]) => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return { exitCode: 124, stdout: "", stderr: "" }; + } + return await exec(args, { + cwd: opts.cwd, + env: opts.env, + stdin: "ignore", + timeoutMs: remaining, + maxStdoutBytes: 65_536, + maxStderrBytes: 4096, + forwardSignals: true, + }); + }; + try { + const config = await probe([...composeArgs, "config", "--images"]); + if (config.exitCode !== 0) { + return config.exitCode; + } + const images = [ + ...new Set( + config.stdout + .split("\n") + .map((image) => image.trim()) + .filter(Boolean) + ), + ]; + if ( + images.length > 256 || + images.some((image) => !UNATTENDED_IMAGE_REFERENCE.test(image)) + ) { + return 1; + } + for (const image of images) { + const reference = + image.includes("@") || + image.slice(image.lastIndexOf("/") + 1).includes(":") + ? image + : `${image}:latest`; + const result = await probe([ + "docker", + "image", + "inspect", + "--format", + "{{.Id}}", + reference, + ]); + if (result.exitCode === 0 && LOCAL_IMAGE_ID.test(result.stdout)) { + continue; + } + if ( + result.exitCode === 1 && + result.stdout.trim() === "" && + result.stderr.trim() === + `Error response from daemon: No such image: ${reference}` + ) { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Autostart requires prepared local images. Run hack up manually before retrying.", + detail: { reason: "image_requires_manual_start" }, + }); + } + return result.exitCode || 1; + } + return 0; + } catch (error: unknown) { + if (error instanceof HackCliError) { + throw error; + } + return 1; + } +} + export const composeRuntimeBackend: RuntimeBackend = { name: "compose", async up(opts) { + if (opts.unattended && !opts.detach) { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Unattended Compose startup requires detached mode; start this project manually.", + detail: { reason: "unattended_requires_detached" }, + }); + } if (opts.detach) { + const composeArgs = [ + ...buildComposeArgs(opts), + ...(opts.unattended ? ["--env-file", "/dev/null"] : []), + ]; const budgetEnv = { ...process.env, ...opts.env }; const startupTimeoutMs = resolveComposeStartupTimeoutMs({ startupTimeoutMs: opts.startupTimeoutMs, @@ -181,12 +282,25 @@ export const composeRuntimeBackend: RuntimeBackend = { // start invocation then only starts the prepared containers and waits on // Compose dependency conditions; --no-build keeps it from rebuilding. const launch = async () => { + if (opts.unattended) { + await opts.beforeStart?.(); + const images = await inspectUnattendedImages( + opts, + composeArgs, + imageTimeoutMs + ); + if (images !== 0) { + return images; + } + await opts.beforeStart?.(); + } opts.onStartupPhase?.("images"); const prepared = await runPhase( [ - ...buildComposeArgs(opts), + ...composeArgs, "up", "--no-start", + ...(opts.unattended ? ["--no-build", "--pull", "never"] : []), ...(opts.forceRecreate ? ["--force-recreate"] : []), ...scope, ], @@ -198,7 +312,14 @@ export const composeRuntimeBackend: RuntimeBackend = { opts.onStartupPhase?.("start"); await opts.beforeStart?.(); return await runPhase( - [...buildComposeArgs(opts), "up", "-d", "--no-build", ...scope], + [ + ...composeArgs, + "up", + "-d", + "--no-build", + ...(opts.unattended ? ["--pull", "never"] : []), + ...scope, + ], startupTimeoutMs ); }; diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index a19bf1965..5f5dbe11a 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -13,6 +13,7 @@ import { assertAutostartProjectCurrent, withAutostartProjectLock, } from "../lib/autostart-project-lock.ts"; +import { assertAutostartStartPolicy } from "../lib/autostart-start-policy.ts"; import { resolveEffectiveBranch } from "../lib/branches.ts"; import { HackCliError } from "../lib/cli-result.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; @@ -281,7 +282,7 @@ export async function withAutostartCommandLock(opts: { login ? { requireAutostartTrust: true } : undefined ); if (login) { - assertLoginCheckoutBeforeDiscovery({ + await assertLoginCheckoutBeforeDiscovery({ projectId: process.env.HACK_AUTOSTART_PROJECT_ID, projects: registry.projects, }); @@ -329,10 +330,10 @@ export async function withAutostartCommandLock(opts: { } /** Check the trusted registry target before even reading repository-controlled inputs. */ -function assertLoginCheckoutBeforeDiscovery(opts: { +async function assertLoginCheckoutBeforeDiscovery(opts: { readonly projectId?: string; readonly projects: readonly RegisteredProject[]; -}): void { +}): Promise { const matches = opts.projects.filter( (project) => project.id === opts.projectId ); @@ -346,6 +347,7 @@ function assertLoginCheckoutBeforeDiscovery(opts: { } assertAutostartCheckoutTrusted(project); assertAutostartDockerConfigTrusted({ home: homedir() }); + await assertAutostartStartPolicy(project); } function assertLoginProjectIdentity(opts: { diff --git a/src/commands/project.ts b/src/commands/project.ts index 4dde6b336..39526126b 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,3 +1,4 @@ +import { assertAutostartHostCommandsAllowed } from "../lib/autostart-refusal.ts"; import { reportLooseEnvLayerModes } from "../lib/env-layer-modes.ts"; import { createLifecycleProcessController, @@ -1962,6 +1963,7 @@ export async function runLifecycleCommands(opts: { readonly signal?: AbortSignal; }): Promise { const commands = opts.commands ?? []; + assertAutostartHostCommandsAllowed({ hasCommands: commands.length > 0 }); for (const [index, cmd] of commands.entries()) { const label = cmd.name ? `${cmd.name}: ${cmd.command}` : cmd.command; logger.step({ message: `${opts.title}: ${label}` }); @@ -2130,6 +2132,13 @@ async function runLifecycleUpBeforeAndProcesses(opts: { // Captured before any preparation hook runs: a down that completes while // the hooks run supersedes this operation (see startProjectSharedHelpers). const sharedHelperConfigs = opts.cfg.lifecycle?.sharedHelpers ?? []; + assertAutostartHostCommandsAllowed({ + hasCommands: + sharedHelperConfigs.length > 0 || + (opts.cfg.lifecycle?.up?.before?.length ?? 0) > 0 || + (opts.cfg.lifecycle?.up?.after?.length ?? 0) > 0 || + (opts.cfg.lifecycle?.processes?.length ?? 0) > 0, + }); await assertSharedHelperSessionMuxSupported({ sharedHelpersConfigured: sharedHelperConfigs.length > 0, needsSession: @@ -5908,6 +5917,10 @@ async function runRemoteLifecycleCommand(opts: { readonly detach?: boolean; readonly services?: readonly string[]; }): Promise { + if (process.env.HACK_AUTOSTART_LOGIN === "1") { + // Machine-local unattended intent never dispatches a host command remotely. + return null; + } const target = await resolveProjectLifecycleTarget({ project: opts.project, requestedTarget: opts.requestedTarget, @@ -6973,6 +6986,8 @@ async function runUpCommand({ assertCurrent: assertHelpersCurrent, up: (beforeStart) => composeRuntimeBackend.up({ + unattended: + process.env.HACK_AUTOSTART_LOGIN === "1" ? true : undefined, beforeStart: async () => { await assertLoginAutostartCurrent(branch, project.projectRoot); await beforeStart?.(); diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index 4b5e01411..c8245e7bf 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -8,6 +8,7 @@ import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client import { GLOBAL_CADDY_COMPOSE_FILENAME, GLOBAL_CADDY_DIR_NAME, + GLOBAL_CONFIG_FILENAME, GLOBAL_PROJECTS_REGISTRY_FILENAME, PROJECT_ENV_CONFIG_FILENAME_PREFIX, PROJECT_ENV_CONFIG_FILENAME_SUFFIX, @@ -17,6 +18,8 @@ import { import { assertAutostartCheckoutTrusted } from "../lib/autostart-checkout-trust.ts"; import { assertAutostartDockerConfigTrusted } from "../lib/autostart-docker-trust.ts"; import { assertAutostartExecutablePathTrusted } from "../lib/autostart-executable-trust.ts"; +import { isAutostartManualStartReason } from "../lib/autostart-refusal.ts"; +import { autostartStartPolicyRefusal } from "../lib/autostart-start-policy.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { readTextFile } from "../lib/fs.ts"; import { isRecord } from "../lib/guards.ts"; @@ -53,6 +56,9 @@ export type AutostartDependencies = { readonly startupRefusal?: () => | AutostartStartupRefusal | Promise; + readonly projectRefusal?: ( + project: RegisteredProject + ) => Promise; readonly now: () => number; readonly sleep: (ms: number, signal?: AbortSignal) => Promise; readonly engineReady: (opts?: { @@ -180,6 +186,25 @@ async function runAutostartLoginPass(opts: { }); return { waitingForEngine: false }; } + const eligible: RegisteredProject[] = []; + for (const project of pending) { + const reason = await deps.projectRefusal?.(project); + if (reason) { + await recordUnclaimedAutostartRefusal({ + pending: [project], + runId: loginRun.runId, + registryPath: opts.registryPath, + deps, + reason, + signal: opts.signal, + }); + } else { + eligible.push(project); + } + } + if (eligible.length === 0) { + return { waitingForEngine: false }; + } const refusal = await prepareAutostartRuntime({ deps, machineBootId, @@ -200,7 +225,7 @@ async function runAutostartLoginPass(opts: { }); await dispatchAutostartProjects({ ...opts, - pending, + pending: eligible, deps, bootId, runId: loginRun.runId, @@ -249,7 +274,7 @@ async function recordUnclaimedAutostartRefusal(opts: { runId: opts.runId, id: project.id, succeeded: false, - cancelled: !recorded, + cancelled: !recorded || isAutostartManualStartReason(opts.reason), registryPath: opts.registryPath, }); } @@ -392,7 +417,7 @@ async function prepareAutostartRuntime(opts: { return null; } -async function startClaimedProject(opts: { +type ClaimedAutostartOptions = { readonly project: RegisteredProject; readonly bootId: string; readonly runId: string; @@ -401,7 +426,11 @@ async function startClaimedProject(opts: { readonly deps: AutostartDependencies; readonly signal?: AbortSignal; readonly registryPath?: string; -}): Promise { +}; + +async function startClaimedProject( + opts: ClaimedAutostartOptions +): Promise { let result: StartResult = { status: "skipped", reason: opts.refusal }; try { const registry = await readProjectsRegistry({ @@ -422,16 +451,7 @@ async function startClaimedProject(opts: { reason: opts.signal?.aborted ? "daemon_stopped" : "paused", }; } else if (!opts.refusal) { - const state = await opts.deps.inspect(current, opts.signal); - if (state === "running") { - result = { status: "already_running", reason: null }; - } else if (state === "untrusted") { - result = { status: "skipped", reason: "checkout_untrusted" }; - } else if (state === "unknown") { - result = { status: "skipped", reason: "runtime_unknown" }; - } else { - result = await dispatchIfStillWanted({ ...opts, project: current }); - } + result = await inspectAndDispatchAutostart({ ...opts, project: current }); } } catch { result = { status: "failed", reason: "start_failed" }; @@ -442,6 +462,10 @@ async function startClaimedProject(opts: { id: opts.project.id, bootId: opts.bootId, result: { ...result, at: new Date(opts.deps.now()).toISOString() }, + releaseUnstartedClaim: + result.status === "skipped" && + isAutostartManualStartReason(result.reason), + expectedBranch: opts.project.autostart?.branch, registryPath: opts.registryPath, }); await recordAutostartLoginOutcome({ @@ -451,13 +475,37 @@ async function startClaimedProject(opts: { result.status === "started" || result.status === "already_running", cancelled: result.reason !== null && - ["paused", "global_paused", "daemon_stopped", "target_changed"].includes( - result.reason - ), + (isAutostartManualStartReason(result.reason) || + [ + "paused", + "global_paused", + "daemon_stopped", + "target_changed", + ].includes(result.reason)), registryPath: opts.registryPath, }); } +async function inspectAndDispatchAutostart( + opts: ClaimedAutostartOptions +): Promise { + const refusal = await opts.deps.projectRefusal?.(opts.project); + if (refusal) { + return { status: "skipped", reason: refusal }; + } + const state = await opts.deps.inspect(opts.project, opts.signal); + if (state === "running") { + return { status: "already_running", reason: null }; + } + if (state === "untrusted") { + return { status: "skipped", reason: "checkout_untrusted" }; + } + if (state === "unknown") { + return { status: "skipped", reason: "runtime_unknown" }; + } + return await dispatchIfStillWanted(opts); +} + async function dispatchIfStillWanted(opts: { readonly project: RegisteredProject; readonly onDispatched: () => void; @@ -535,7 +583,8 @@ export function classifyAutostartExit(opts: { if ( code === "E_PERMISSION" && isRecord(detail) && - (detail.reason === "checkout_untrusted" || + (isAutostartManualStartReason(detail.reason) || + detail.reason === "checkout_untrusted" || detail.reason === "executable_path_untrusted" || detail.reason === "docker_config_untrusted") ) { @@ -594,6 +643,11 @@ export function createAutostartProductionDependencies( ]), ]; return { + projectRefusal: (project) => + autostartStartPolicyRefusal({ + ...project, + globalConfigPath: resolve(runtime.hackHome, GLOBAL_CONFIG_FILENAME), + }), startupRefusal: async () => { try { await (checks?.executablePath ?? assertAutostartExecutablePathTrusted)({ diff --git a/src/lib/autostart-project-lock.ts b/src/lib/autostart-project-lock.ts index d56f15460..614f8e797 100644 --- a/src/lib/autostart-project-lock.ts +++ b/src/lib/autostart-project-lock.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; import { assertAutostartDockerConfigTrusted } from "./autostart-docker-trust.ts"; +import { assertAutostartStartPolicy } from "./autostart-start-policy.ts"; import { HackCliError } from "./cli-result.ts"; import { readProjectsRegistry, @@ -104,5 +105,8 @@ export async function assertAutostartProjectCurrent(opts: { repoRoot: selected.repoRoot, projectDir: selected.projectDir, }); + if (process.env.HACK_AUTOSTART_LOGIN === "1") { + await assertAutostartStartPolicy(selected); + } } } diff --git a/src/lib/autostart-refusal.ts b/src/lib/autostart-refusal.ts new file mode 100644 index 000000000..8038267c1 --- /dev/null +++ b/src/lib/autostart-refusal.ts @@ -0,0 +1,32 @@ +import { HackCliError } from "./cli-result.ts"; + +const MANUAL_START_REASONS = new Set([ + "host_commands_require_manual_start", + "image_build_requires_manual_start", + "image_requires_manual_start", + "command_provider_requires_manual_start", + "compose_inputs_require_manual_start", + "env_file_requires_manual_start", + "pending_env_migration", +]); + +/** Closed pre-start refusals do not consume a claim or report a startup failure. */ +export function isAutostartManualStartReason( + reason: unknown +): reason is string { + return typeof reason === "string" && MANUAL_START_REASONS.has(reason); +} + +/** Enforce the boundary again at host-execution sites, using their actual inputs. */ +export function assertAutostartHostCommandsAllowed(opts: { + readonly hasCommands: boolean; +}): void { + if (process.env.HACK_AUTOSTART_LOGIN === "1" && opts.hasCommands) { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "This project requires host commands; start it manually. No unattended host command ran.", + detail: { reason: "host_commands_require_manual_start" }, + }); + } +} diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts new file mode 100644 index 000000000..e29da6660 --- /dev/null +++ b/src/lib/autostart-start-policy.ts @@ -0,0 +1,403 @@ +import { constants } from "node:fs"; +import { type FileHandle, open, readdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { YAML } from "bun"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, + PROJECT_ENV_CONFIG_FILENAME_PREFIX, + PROJECT_ENV_CONFIG_FILENAME_SUFFIX, + PROJECT_ENV_CONTRACT_FILENAME, + PROJECT_ENV_STATE_FILENAME, +} from "../constants.ts"; +import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; +import { HackCliError } from "./cli-result.ts"; +import { resolveGlobalConfigPath } from "./config-paths.ts"; +import { isRecord } from "./guards.ts"; +import { checkReplaceableChain } from "./path-trust.ts"; +import { + findProjectContextAtRoot, + type ProjectDirName, + parseProjectConfigRecord, +} from "./project.ts"; + +export type AutostartStartPolicyReason = + | "host_commands_require_manual_start" + | "image_build_requires_manual_start" + | "command_provider_requires_manual_start" + | "compose_inputs_require_manual_start" + | "checkout_untrusted" + | "pending_env_migration"; +export type AutostartStartPolicyOptions = { + readonly repoRoot: string; + readonly projectDir: string; + readonly projectDirName: ProjectDirName; + /** Explicit-path seam; production reads the owned machine configuration. */ + readonly globalConfigPath?: string; +}; +const MAX_INPUT_BYTES = 1024 * 1024; +const MAX_TOTAL_BYTES = 4 * MAX_INPUT_BYTES; +const MAX_ENV_FILES = 64; +const HOST_CONTROL_ENV_KEY = + /^(?:COMPOSE_|GIT_|DOCKER_|HACK_|XDG_|LD_|DYLD_|BUN_|HOME$|USER$|LOGNAME$|TMPDIR$|TMP$|TEMP$|SHELL$|PATH$|NODE_OPTIONS$|NODE_PATH$|BASH_ENV$|ENV$|SHELLOPTS$|IFS$|CDPATH$|GCONV_PATH$|PYTHON(?:PATH|HOME|STARTUP|INSPECT)$|RUBYOPT$|RUBYLIB$|PERL5OPT$|PERL5LIB$)/; +const DEPENDENCY_INSTALL = + /(?:^|\s)(?:bun|npm|pnpm|yarn|uv|pip)(?:\s+run)?\s+(?:install|ci|sync)(?:\s|$)/i; +type Budget = { remaining: number }; + +/** Read-only unattended admission. It never resolves a secret, provider or command. */ +export async function autostartStartPolicyRefusal( + opts: AutostartStartPolicyOptions +): Promise { + try { + assertAutostartCheckoutTrusted(opts); + } catch { + return "checkout_untrusted"; + } + try { + if ( + resolve(opts.repoRoot, opts.projectDirName) !== resolve(opts.projectDir) + ) { + return "compose_inputs_require_manual_start"; + } + const context = await findProjectContextAtRoot({ + projectRoot: opts.repoRoot, + projectDirName: opts.projectDirName, + }); + if (!context) { + return "compose_inputs_require_manual_start"; + } + const budget = { remaining: MAX_TOTAL_BYTES }; + const jsonPath = resolve(opts.projectDir, PROJECT_CONFIG_FILENAME); + const json = await readPolicyText(jsonPath, budget); + const path = + json === null + ? resolve(opts.projectDir, PROJECT_CONFIG_LEGACY_FILENAME) + : jsonPath; + const text = json ?? (await readPolicyText(path, budget)); + let raw: unknown = {}; + if (text !== null) { + raw = json === null ? Bun.TOML.parse(text) : JSON.parse(text); + } + const config = parseProjectConfigRecord(raw, path); + if (config.parseError || !isRecord(raw)) { + return "compose_inputs_require_manual_start"; + } + if (hasHostCommands(raw)) { + return "host_commands_require_manual_start"; + } + const globalText = await readPolicyText( + opts.globalConfigPath ?? resolveGlobalConfigPath(), + budget + ); + const global: unknown = globalText === null ? {} : JSON.parse(globalText); + if (!isRecord(global)) { + return "compose_inputs_require_manual_start"; + } + if (hasUnsupportedProvider(raw) || hasUnsupportedProvider(global)) { + return "command_provider_requires_manual_start"; + } + const composeText = await readPolicyText( + resolve(opts.projectDir, PROJECT_COMPOSE_FILENAME), + budget + ); + if (composeText === null) { + return "compose_inputs_require_manual_start"; + } + const compose: unknown = YAML.parse(composeText); + const composeReason = composePolicyReason(compose); + if (composeReason) { + return composeReason; + } + return await envPolicyReason({ ...opts, budget }); + } catch { + return "compose_inputs_require_manual_start"; + } +} + +/** Refuse before effects with a fixed value-free reason that can release an unstarted claim. */ +export async function assertAutostartStartPolicy( + opts: AutostartStartPolicyOptions +): Promise { + const reason = await autostartStartPolicyRefusal(opts); + if (reason) { + throw new HackCliError({ + code: + reason === "pending_env_migration" + ? "E_CONFIG_INVALID" + : "E_PERMISSION", + message: + reason === "pending_env_migration" + ? "Autostart skipped: pending env migration. Run hack doctor --migrate-env-config." + : "This project requires an explicit manual start. Run hack up manually; no further unattended startup effects ran. Values omitted.", + detail: { reason }, + }); + } +} + +function present(value: unknown): boolean { + if (value === undefined || value === null) { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (isRecord(value)) { + return Object.keys(value).length > 0; + } + return true; +} + +function hasHostCommands(raw: Record): boolean { + if (present(raw.startup)) { + return true; + } + if (!isRecord(raw.lifecycle)) { + return present(raw.lifecycle); + } + return [ + raw.lifecycle.up, + raw.lifecycle.down, + raw.lifecycle.processes, + raw.lifecycle.sharedHelpers, + ].some(present); +} + +function hasUnsupportedProvider(raw: Record): boolean { + const controlPlane = isRecord(raw.controlPlane) ? raw.controlPlane : {}; + const secrets = isRecord(controlPlane.secrets) ? controlPlane.secrets : {}; + const env = isRecord(raw.env) ? raw.env : {}; + return ( + (secrets.backend !== undefined && + !["keychain", "encrypted_file"].includes(String(secrets.backend))) || + secrets.backend === "command" || + present(secrets.cloud) || + present(secrets.command) || + present(secrets.provider) || + present(env.command) || + present(env.providers) || + present(env.provider) || + present(raw.secretsprovider) + ); +} + +function composePolicyReason( + compose: unknown +): AutostartStartPolicyReason | null { + if ( + !(isRecord(compose) && isRecord(compose.services)) || + Object.keys(compose.services).length === 0 || + compose.include !== undefined || + compose["<<"] !== undefined + ) { + return "compose_inputs_require_manual_start"; + } + for (const service of Object.values(compose.services)) { + const reason = servicePolicyReason(service); + if (reason) { + return reason; + } + } + return null; +} + +function servicePolicyReason( + service: unknown +): AutostartStartPolicyReason | null { + if (!isRecord(service)) { + return "compose_inputs_require_manual_start"; + } + if (service.build !== undefined) { + return "image_build_requires_manual_start"; + } + if (service.extends !== undefined || service.env_file !== undefined) { + return "compose_inputs_require_manual_start"; + } + if (service.provider !== undefined) { + return "command_provider_requires_manual_start"; + } + const labels = service.labels; + let names: readonly string[] = []; + if (Array.isArray(labels)) { + names = labels + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.split("=")[0] ?? ""); + } else if (isRecord(labels)) { + names = Object.keys(labels); + } + if (names.some((name) => name.startsWith("hack.dependencies."))) { + return "host_commands_require_manual_start"; + } + const command = [service.entrypoint, service.command] + .map(commandText) + .join(" "); + return DEPENDENCY_INSTALL.test(command) + ? "host_commands_require_manual_start" + : null; +} + +function commandText(value: unknown): string { + if (Array.isArray(value)) { + return value.join(" "); + } + return typeof value === "string" ? value : ""; +} + +async function envPolicyReason( + opts: AutostartStartPolicyOptions & { readonly budget: Budget } +): Promise { + for (const directory of new Set([opts.repoRoot, opts.projectDir])) { + const entries = await readdir(directory); + const inputs = entries.filter(isEnvironmentInput); + if (inputs.length > MAX_ENV_FILES) { + return "compose_inputs_require_manual_start"; + } + for (const name of inputs) { + const text = await readPolicyText(resolve(directory, name), opts.budget); + if (text === null) { + return "compose_inputs_require_manual_start"; + } + const reason = environmentInputReason({ name, text }); + if (reason) { + return reason; + } + } + } + return null; +} + +function isEnvironmentInput(name: string): boolean { + return ( + (name.startsWith(".env") && name !== PROJECT_ENV_STATE_FILENAME) || + name === PROJECT_ENV_CONTRACT_FILENAME || + (name.startsWith(PROJECT_ENV_CONFIG_FILENAME_PREFIX) && + name.endsWith(PROJECT_ENV_CONFIG_FILENAME_SUFFIX)) + ); +} + +function environmentInputReason(opts: { + readonly name: string; + readonly text: string; +}): AutostartStartPolicyReason | null { + if (!opts.name.endsWith(PROJECT_ENV_CONFIG_FILENAME_SUFFIX)) { + return opts.text.trim() === "" ? null : "pending_env_migration"; + } + const layer: unknown = YAML.parse(opts.text); + if (!isRecord(layer) || layer.secretsprovider !== "project_key") { + return "command_provider_requires_manual_start"; + } + if (layer.version !== 1 || !isRecord(layer.values)) { + return "compose_inputs_require_manual_start"; + } + if ( + present(layer.providers) || + present(layer.command) || + present(layer.provider) || + present(layer.cloud) + ) { + return "command_provider_requires_manual_start"; + } + for (const values of Object.values(layer.values)) { + const reason = environmentScopeReason(values); + if (reason) { + return reason; + } + } + return null; +} + +function environmentScopeReason( + values: unknown +): AutostartStartPolicyReason | null { + if (!isRecord(values)) { + return "compose_inputs_require_manual_start"; + } + for (const [key, value] of Object.entries(values)) { + if (HOST_CONTROL_ENV_KEY.test(key)) { + return "compose_inputs_require_manual_start"; + } + if ( + isRecord(value) && + (Object.keys(value).length !== 1 || typeof value.secure !== "string") + ) { + return "command_provider_requires_manual_start"; + } + if (Array.isArray(value)) { + return "command_provider_requires_manual_start"; + } + } + return null; +} + +async function readPolicyText( + path: string, + budget: Budget +): Promise { + const uid = process.getuid?.(); + if ( + uid === undefined || + checkReplaceableChain({ + path: dirname(path), + uid, + hackHome: null, + }) !== null + ) { + throw new Error("Unsafe input parent"); + } + let file: FileHandle; + try { + file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + } catch (error: unknown) { + if ( + isRecord(error) && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ) { + return null; + } + throw error; + } + try { + const before = await file.stat(); + if ( + !before.isFile() || + before.uid !== uid || + (before.mode & 0o022) !== 0 || + before.size > MAX_INPUT_BYTES || + before.size > budget.remaining + ) { + throw new Error("Unsupported input"); + } + const bytes = new Uint8Array(before.size + 1); + let length = 0; + while (length < bytes.length) { + const result = await file.read( + bytes, + length, + bytes.length - length, + length + ); + if (result.bytesRead === 0) { + break; + } + length += result.bytesRead; + } + const after = await file.stat(); + if ( + length !== before.size || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs || + after.ctimeMs !== before.ctimeMs + ) { + throw new Error("Input changed"); + } + budget.remaining -= length; + return new TextDecoder("utf-8", { fatal: true }).decode( + bytes.subarray(0, length) + ); + } finally { + await file.close(); + } +} diff --git a/src/lib/autostart-trust-diagnostics.ts b/src/lib/autostart-trust-diagnostics.ts index 5a0a2853c..7acd2bae0 100644 --- a/src/lib/autostart-trust-diagnostics.ts +++ b/src/lib/autostart-trust-diagnostics.ts @@ -2,6 +2,7 @@ import { autostartServicePath } from "../daemon/autostart.ts"; import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; import { assertAutostartDockerConfigTrusted } from "./autostart-docker-trust.ts"; import { assertAutostartExecutablePathTrusted } from "./autostart-executable-trust.ts"; +import { autostartStartPolicyRefusal } from "./autostart-start-policy.ts"; import type { RegisteredProject } from "./projects-registry.ts"; /** File-only doctor observation; never executes helpers or repairs permissions. */ @@ -17,6 +18,10 @@ export async function autostartTrustWarnings(opts: { } try { assertAutostartCheckoutTrusted(project); + const reason = await autostartStartPolicyRefusal(project); + if (reason) { + warnings.add(reason); + } } catch { warnings.add("checkout_untrusted"); } diff --git a/src/lib/child-environment.ts b/src/lib/child-environment.ts index 086f5b198..9f021dade 100644 --- a/src/lib/child-environment.ts +++ b/src/lib/child-environment.ts @@ -39,6 +39,24 @@ export function childEnvironment( for (const key of HACK_KEY_MATERIAL_VARIABLES) { delete env[key]; } + if (base.HACK_AUTOSTART_LOGIN === "1") { + // Login's Git calls observe identity, tracking and ignore state only. Do + // not load global/system config or execute repository monitor/hook code. + for (const key of Object.keys(env)) { + if (key.startsWith("GIT_CONFIG_")) { + delete env[key]; + } + } + Object.assign(env, { + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "core.fsmonitor", + GIT_CONFIG_VALUE_0: "false", + GIT_CONFIG_KEY_1: "core.hooksPath", + GIT_CONFIG_VALUE_1: "/dev/null", + }); + } return env; } diff --git a/src/lib/dependency-cache-bootstrap.ts b/src/lib/dependency-cache-bootstrap.ts index 403f1e8fa..a21391d06 100644 --- a/src/lib/dependency-cache-bootstrap.ts +++ b/src/lib/dependency-cache-bootstrap.ts @@ -2,6 +2,7 @@ import { composeRuntimeBackend, type RuntimeBaseOptions, } from "../backends/runtime-backend.ts"; +import { assertAutostartHostCommandsAllowed } from "./autostart-refusal.ts"; import { COMPOSE_STARTUP_TIMEOUT_ENV } from "./compose-startup-budget.ts"; const BOOTSTRAP_TIMEOUT_MS = 600_000; @@ -13,6 +14,7 @@ export async function bootstrapDependencyCaches( readonly timeoutMs?: number; } ): Promise { + assertAutostartHostCommandsAllowed({ hasCommands: opts.services.length > 0 }); for (const service of opts.services) { process.stderr.write(`Initializing dependency cache with ${service}\n`); const code = await composeRuntimeBackend.run({ diff --git a/src/lib/project.ts b/src/lib/project.ts index 885ec255c..b4ae00b43 100644 --- a/src/lib/project.ts +++ b/src/lib/project.ts @@ -523,8 +523,12 @@ function parseProjectConfigToml(opts: { return parseProjectConfigRecord(parsed, opts.path); } +/** Parse an already bounded configuration observation without additional file reads. */ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Project config parsing keeps schema migration and legacy compatibility explicit in one parser. -function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig { +export function parseProjectConfigRecord( + value: unknown, + path: string +): ProjectConfig { if (!isRecord(value)) { return { configPath: path, diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index dbf18d8a6..e98203ac2 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -4,6 +4,7 @@ import { constants } from "node:fs"; import { type FileHandle, open, realpath } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { GLOBAL_PROJECTS_REGISTRY_FILENAME } from "../constants.ts"; +import { isAutostartManualStartReason } from "./autostart-refusal.ts"; import { HackCliError } from "./cli-result.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { ensureDir, pathExists } from "./fs.ts"; @@ -1184,7 +1185,11 @@ export async function claimProjectAutostart( return claimed; } -/** A late result cannot recreate removed intent or overwrite a newer boot claim. */ +/** + * A late result cannot recreate removed intent or overwrite a newer boot/branch + * claim. Closed manual-only refusals may release an unstarted claim atomically; + * failures, unknown reasons and results lacking the expected target never do. + */ export async function recordProjectAutostartResult( opts: AutostartRegistryOptions & { readonly id: string; @@ -1193,6 +1198,7 @@ export async function recordProjectAutostartResult( readonly result: ProjectAutostartResult; readonly onlyIfWanted?: boolean; readonly expectedBranch?: string | null; + readonly releaseUnstartedClaim?: boolean; } ): Promise { let recorded = false; @@ -1210,9 +1216,19 @@ export async function recordProjectAutostartResult( return project; } recorded = true; + const release = + opts.releaseUnstartedClaim === true && + opts.bootId !== null && + opts.expectedBranch !== undefined && + opts.result.status === "skipped" && + isAutostartManualStartReason(opts.result.reason); return { ...project, - autostart: { ...project.autostart, lastResult: opts.result }, + autostart: { + ...project.autostart, + lastResult: opts.result, + ...(release ? { lastAttemptBootId: null } : {}), + }, }; }, }); diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index 2ae5269f6..890ac20cc 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -26,6 +26,7 @@ import type { } from "../control-plane/sdk/config.ts"; import { readControlPlaneConfig } from "../control-plane/sdk/config.ts"; import { childEnvironment } from "./child-environment.ts"; +import { HackCliError } from "./cli-result.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { envParseDiagnostic } from "./env-parse-diagnostic.ts"; import { ensureDir } from "./fs.ts"; @@ -162,6 +163,17 @@ export async function resolveSecretStore(input: { ...(input.projectDir ? { projectDir: input.projectDir } : {}), }); const secretsConfig = controlPlane.config.secrets; + if ( + process.env.HACK_AUTOSTART_LOGIN === "1" && + secretsConfig.backend === "cloud" + ) { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Automatic startup does not run external secret providers; start this project manually.", + detail: { reason: "command_provider_requires_manual_start" }, + }); + } if (secretsConfig.backend === "encrypted_file") { return await createEncryptedFileSecretStore({ projectName: input.projectName, diff --git a/src/lib/shell.ts b/src/lib/shell.ts index e0344d975..8505a8a66 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -32,6 +32,10 @@ export interface ExecOptions { * held in memory. */ readonly maxStdoutBytes?: number; + /** Opt-in bounded stderr capture; both streams are drained concurrently. */ + readonly maxStderrBytes?: number; + /** Forward OS cancellation to the owned capture group and await settlement. */ + readonly forwardSignals?: boolean; } /** Exit code `exec` reports when `maxStdoutBytes` was exceeded. */ @@ -73,6 +77,9 @@ export async function exec( if (timeoutMs !== undefined && timeoutMs <= 0) { return { exitCode: 124, stdout: "", stderr: "" }; } + if (opts.maxStderrBytes !== undefined || opts.forwardSignals) { + return await execBoundedOwned(cmd, opts, timeoutMs); + } const proc = Bun.spawn([...cmd], { cwd: opts.cwd, env: buildSpawnEnv(opts.env, opts.unsetEnvKeys), @@ -132,6 +139,108 @@ export async function exec( }; } +/** New bounded-capture callers own the child group and never infer absence from a failed probe. */ +async function execBoundedOwned( + cmd: readonly string[], + opts: ExecOptions, + timeoutMs: number | undefined +): Promise { + const abort = new AbortController(); + const proc = Bun.spawn([...cmd], { + cwd: opts.cwd, + env: buildSpawnEnv(opts.env, opts.unsetEnvKeys), + stdin: opts.stdin ?? "ignore", + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + let exited = false; + let failedCapture = false; + let drainTimer: ReturnType | undefined; + const timeout = installSubprocessTimeout({ pid: proc.pid, timeoutMs }); + const cancellation = installSubprocessSignalForwarding({ + pid: proc.pid, + forwardSignals: opts.forwardSignals === true, + }); + const observeCpu = beginNativeCpuChild(proc, nativeCpuCommandCategory(cmd)); + const failCapture = () => { + failedCapture = true; + if (!exited && proc.exitCode === null) { + try { + process.kill(-proc.pid, "SIGKILL"); + } catch { + proc.kill("SIGKILL"); + } + } + abort.abort(); + }; + const capture = async ( + stream: ReadableStream, + limit: number + ): Promise => { + try { + const bytes = await readBoundedStream(stream, limit, abort.signal, () => { + failCapture(); + throw new StdoutOverflow(); + }); + return Buffer.from(bytes).toString("latin1"); + } catch { + failCapture(); + return ""; + } + }; + const stdout = capture(proc.stdout, opts.maxStdoutBytes ?? 65_536); + const stderr = capture(proc.stderr, opts.maxStderrBytes ?? 4096); + const completion = (async () => { + try { + const code = await proc.exited; + observeCpu(code); + return code; + } finally { + exited = true; + timeout.dispose(); + cancellation.dispose(); + drainTimer = setTimeout(failCapture, 200); + } + })(); + try { + const [code, stdoutText, stderrText] = await Promise.all([ + completion, + stdout, + stderr, + ]); + if ( + (failedCapture || + timeout.didTimeout() || + cancellation.exitCode() !== null) && + !(await waitForOwnedProcessGroupSettlement({ processGroupId: proc.pid })) + ) { + throw new Error( + "Captured subprocess group settlement could not be confirmed. Values omitted." + ); + } + let exitCode = failedCapture ? EXEC_STDOUT_OVERFLOW_EXIT : code; + if (timeout.didTimeout()) { + exitCode = 124; + } + exitCode = cancellation.exitCode() ?? exitCode; + return { + exitCode, + stdout: failedCapture ? "" : stdoutText, + stderr: failedCapture ? "" : stderrText, + }; + } finally { + clearTimeout(drainTimer); + timeout.dispose(); + cancellation.dispose(); + abort.abort(); + if (!exited && proc.exitCode === null) { + failCapture(); + } + await Promise.allSettled([completion, stdout, stderr]); + } +} + export interface RunOptions { /** Deliberate Hack re-exec only: retain credentials needed to decrypt layers. */ readonly hackReexec?: boolean; @@ -403,6 +512,9 @@ function installSubprocessSignalForwarding(opts: { return { exitCode: () => exitCode, dispose: () => { + if (!active) { + return; + } active = false; process.off("SIGINT", onInterrupt); process.off("SIGTERM", onTerminate); @@ -437,6 +549,7 @@ function installSubprocessTimeout(opts: { } } }; + let disposed = false; const timer = setTimeout(() => { timedOut = true; signalProcessGroup("SIGTERM"); @@ -444,6 +557,10 @@ function installSubprocessTimeout(opts: { }, opts.timeoutMs); return { dispose: () => { + if (disposed) { + return; + } + disposed = true; clearTimeout(timer); if (forceKillTimer) { clearTimeout(forceKillTimer); diff --git a/tests/autostart-checkout-trust.test.ts b/tests/autostart-checkout-trust.test.ts index 5dfd52a9c..9d6b629f9 100644 --- a/tests/autostart-checkout-trust.test.ts +++ b/tests/autostart-checkout-trust.test.ts @@ -252,16 +252,21 @@ test("a final pre-start recheck observes checkout trust changes after initial ad test.each([ "directory", "config", + "hook", ])("source login CLI refuses a writable %s before hooks or engine commands", async (kind) => { const hook = join(root, "hook-ran"); const engine = join(root, "engine-ran"); const bin = join(root, "bin"); await mkdir(bin, { mode: 0o700 }); + const script = join(repoRoot, "start-hook.sh"); + await writeFile(script, `printf ran > ${quoteShellWord(hook)}\n`, { + mode: 0o600, + }); await writeFile( join(projectDir, "hack.config.json"), JSON.stringify({ name: "example", - lifecycle: { up: { before: [`printf ran > ${quoteShellWord(hook)}`] } }, + lifecycle: { up: { before: [`sh ${quoteShellWord(script)}`] } }, }) ); await writeFile( @@ -270,10 +275,13 @@ test.each([ { mode: 0o700 } ); const unsafe = - kind === "directory" - ? join(root, "projects") - : join(projectDir, "hack.config.json"); - await chmod(unsafe, 0o770); + kind === "hook" + ? script + : kind === "directory" + ? join(root, "projects") + : join(projectDir, "hack.config.json"); + const mode = kind === "hook" ? 0o666 : 0o770; + await chmod(unsafe, mode); const child = Bun.spawn( [ process.execPath, @@ -307,8 +315,12 @@ test.each([ child.exited, ]); expect(code, stdout + stderr).not.toBe(0); - expect(stdout + stderr).toContain("checkout_untrusted"); + expect(stdout + stderr).toContain( + kind === "hook" + ? "host_commands_require_manual_start" + : "checkout_untrusted" + ); expect(await Bun.file(hook).exists()).toBe(false); expect(await Bun.file(engine).exists()).toBe(false); - expect((await stat(unsafe)).mode & 0o777).toBe(0o770); + expect((await stat(unsafe)).mode & 0o777).toBe(mode); }); diff --git a/tests/autostart-host-boundary.test.ts b/tests/autostart-host-boundary.test.ts new file mode 100644 index 000000000..d9c07de5f --- /dev/null +++ b/tests/autostart-host-boundary.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runLifecycleCommands } from "../src/commands/project.ts"; +import { bootstrapDependencyCaches } from "../src/lib/dependency-cache-bootstrap.ts"; +import { quoteShellWord } from "../src/lib/logs.ts"; +import { resolveSecretStore } from "../src/lib/secret-store.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +test("the actual hook runner refuses unattended code while manual execution remains available", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-host-boundary-")); + const previous = process.env.HACK_AUTOSTART_LOGIN; + try { + const projectDir = join(root, ".hack"); + await mkdir(projectDir, { mode: 0o700 }); + const receipt = join(root, "hook-ran"); + const options = { + title: "fixture startup", + commands: [{ command: `printf manual > ${quoteShellWord(receipt)}` }], + projectRoot: root, + projectDir, + composeProject: "fixture", + env: { PATH: "/usr/bin:/bin" }, + routeStdoutToStderr: true, + }; + process.env.HACK_AUTOSTART_LOGIN = "1"; + await expect(runLifecycleCommands(options)).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "host_commands_require_manual_start" }, + }); + expect(await Bun.file(receipt).exists()).toBe(false); + await expect( + bootstrapDependencyCaches({ + composeFiles: [join(projectDir, "docker-compose.yml")], + cwd: root, + services: ["installer"], + env: { PATH: "/nonexistent" }, + }) + ).rejects.toMatchObject({ + detail: { reason: "host_commands_require_manual_start" }, + }); + process.env.HACK_AUTOSTART_LOGIN = "0"; + expect(await runLifecycleCommands(options)).toBe(0); + expect(await Bun.file(receipt).text()).toBe("manual"); + } finally { + restoreEnv("HACK_AUTOSTART_LOGIN", previous); + await rm(root, { recursive: true, force: true }); + } +}); + +test("the runtime secret-store boundary refuses external providers before adapter setup", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-provider-boundary-")); + const previous = process.env.HACK_AUTOSTART_LOGIN; + const previousHome = process.env.HOME; + try { + process.env.HOME = root; + process.env.HACK_AUTOSTART_LOGIN = "1"; + const projectDir = join(root, "project", ".hack"); + await mkdir(projectDir, { recursive: true, mode: 0o700 }); + await writeFile( + join(projectDir, "hack.config.json"), + JSON.stringify({ + controlPlane: { + secrets: { backend: "cloud", cloud: { provider: "aws" } }, + }, + }) + ); + await expect( + resolveSecretStore({ projectName: "fixture", projectDir }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "command_provider_requires_manual_start" }, + }); + expect( + await Bun.file(join(root, ".hack", "secrets-file.key")).exists() + ).toBe(false); + } finally { + restoreEnv("HOME", previousHome); + restoreEnv("HACK_AUTOSTART_LOGIN", previous); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts new file mode 100644 index 000000000..c16e501a0 --- /dev/null +++ b/tests/autostart-start-policy.test.ts @@ -0,0 +1,314 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { YAML } from "bun"; +import { + type AutostartStartPolicyOptions, + assertAutostartStartPolicy, + autostartStartPolicyRefusal, +} from "../src/lib/autostart-start-policy.ts"; + +let root = ""; +let opts: AutostartStartPolicyOptions; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-start-policy-")); + const repoRoot = join(root, "project"); + const projectDir = join(repoRoot, ".hack"); + await mkdir(projectDir, { recursive: true, mode: 0o700 }); + opts = { + repoRoot, + projectDir, + projectDirName: ".hack", + globalConfigPath: join(root, "global.json"), + }; + await writeFile( + join(projectDir, "docker-compose.yml"), + YAML.stringify({ + services: { web: { image: "example/web:1", command: ["serve"] } }, + }), + { mode: 0o600 } + ); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +async function config(value: unknown) { + await writeFile( + join(opts.projectDir, "hack.config.json"), + JSON.stringify(value), + { mode: 0o600 } + ); +} +async function compose(value: unknown) { + await writeFile( + join(opts.projectDir, "docker-compose.yml"), + YAML.stringify(value), + { mode: 0o600 } + ); +} +async function layer(value: unknown, name = "hack.env.default.yaml") { + await writeFile(join(opts.projectDir, name), YAML.stringify(value), { + mode: 0o600, + }); +} +async function snapshot(directory: string): Promise { + const result: string[] = []; + for (const entry of (await readdir(directory, { withFileTypes: true })).sort( + (a, b) => a.name.localeCompare(b.name) + )) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + result.push( + ...(await snapshot(path)).map((value) => `${entry.name}/${value}`) + ); + } else { + result.push( + `${entry.name}:${(await stat(path)).mode}:${await readFile(path, "base64")}` + ); + } + } + return result; +} + +test("simple image-only Compose and local encrypted app env pass without changing any input", async () => { + await config({ name: "example", lifecycle: {} }); + await layer({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { + global: { + APP_MODE: "local", + APP_TOKEN: { secure: "opaque-ciphertext-not-resolved" }, + }, + web: { PORT: 3000 }, + }, + }); + await writeFile(join(opts.projectDir, ".env.state.json"), "{}", { + mode: 0o600, + }); + const before = await snapshot(root); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); + await expect(assertAutostartStartPolicy(opts)).resolves.toBeUndefined(); + expect(await snapshot(root)).toEqual(before); +}); + +test("startup aliases, lifecycle phases, processes and shared helpers require manual starts", async () => { + for (const value of [ + { startup: [{ run: "synthetic-command" }] }, + { lifecycle: { up: { before: ["synthetic-command"] } } }, + { lifecycle: { down: { after: ["synthetic-command"] } } }, + { + lifecycle: { + processes: [{ name: "tunnel", command: "synthetic-command" }], + }, + }, + { + lifecycle: { + sharedHelpers: [ + { + name: "helper", + command: "synthetic-command", + port: 1234, + upstream: "http://127.0.0.1:1234", + }, + ], + }, + }, + ]) { + await config(value); + expect(await autostartStartPolicyRefusal(opts)).not.toBeNull(); + await expect(assertAutostartStartPolicy(opts)).rejects.toMatchObject({ + code: "E_PERMISSION", + }); + } +}); + +test("legacy TOML startup is checked by the same canonical parser", async () => { + await writeFile( + join(opts.projectDir, "hack.toml"), + 'name = "example"\n[[startup]]\nrun = "synthetic-command"\n', + { mode: 0o600 } + ); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "host_commands_require_manual_start" + ); +}); + +test("builds, ambiguous Compose graph sources, external providers and env files never start unattended", async () => { + for (const [service, reason] of [ + [ + { image: "example/web:1", build: "." }, + "image_build_requires_manual_start", + ], + [ + { extends: { file: "other.yml", service: "web" } }, + "compose_inputs_require_manual_start", + ], + [ + { image: "example/web:1", env_file: ["app.env"] }, + "compose_inputs_require_manual_start", + ], + [ + { provider: { type: "command", command: "synthetic-command" } }, + "command_provider_requires_manual_start", + ], + [ + { + image: "example/web:1", + labels: { "hack.dependencies.bootstrap": "true" }, + }, + "host_commands_require_manual_start", + ], + [ + { image: "example/web:1", command: "npm install" }, + "host_commands_require_manual_start", + ], + ] as const) { + await compose({ services: { web: service } }); + expect(await autostartStartPolicyRefusal(opts)).toBe(reason); + } + await compose({ + include: ["other.yml"], + services: { web: { image: "example/web:1" } }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); +}); + +test("global and project command/cloud provider declarations refuse without invocation", async () => { + for (const value of [ + { env: { provider: { command: "synthetic-command" } } }, + { + controlPlane: { + secrets: { backend: "cloud", cloud: { provider: "aws" } }, + }, + }, + ]) { + await config(value); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "command_provider_requires_manual_start" + ); + } + await config({}); + await writeFile( + opts.globalConfigPath ?? "", + JSON.stringify({ + controlPlane: { + secrets: { backend: "command", command: "synthetic-command" }, + }, + }), + { mode: 0o600 } + ); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "command_provider_requires_manual_start" + ); +}); + +test("managed graph and process-control keys refuse by name even when ciphertext is opaque", async () => { + for (const key of [ + "COMPOSE_PROFILES", + "COMPOSE_FILE", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_SSH_COMMAND", + "DOCKER_HOST", + "HOME", + "XDG_CONFIG_HOME", + "PYTHONHOME", + "PATH", + "LD_PRELOAD", + "HACK_RUNTIME_BACKEND", + ]) { + await layer({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: { [key]: { secure: "opaque-never-decrypted" } } }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); + } + await layer({ + version: 1, + environment: "default", + secretsprovider: "command", + command: "synthetic-command", + values: { global: {} }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "command_provider_requires_manual_start" + ); +}); + +test("invalid, oversized, symlinked and unsafe inputs refuse with value-free errors", async () => { + await writeFile( + join(opts.projectDir, "hack.config.json"), + "private-sentinel", + { mode: 0o600 } + ); + await expect(assertAutostartStartPolicy(opts)).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "compose_inputs_require_manual_start" }, + }); + try { + await assertAutostartStartPolicy(opts); + } catch (error) { + expect(String(error)).not.toContain("private-sentinel"); + expect(String(error)).not.toContain(root); + } + await writeFile( + join(opts.projectDir, "hack.config.json"), + " ".repeat(1024 * 1024 + 1), + { mode: 0o600 } + ); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); + await rm(join(opts.projectDir, "hack.config.json")); + const outside = join(root, "other.json"); + await writeFile(outside, "{}", { mode: 0o600 }); + await symlink(outside, join(opts.projectDir, "hack.config.json")); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); + await rm(join(opts.projectDir, "hack.config.json")); + await chmod(opts.projectDir, 0o777); + expect(await autostartStartPolicyRefusal(opts)).toBe("checkout_untrusted"); +}); + +test("legacy dotenv and contracts preserve the fixed pending migration refusal without changes", async () => { + for (const name of [".env", "hack.env.json"]) { + const path = join(opts.projectDir, name); + const text = + name === ".env" + ? "SAMPLE_VALUE=private-sentinel" + : JSON.stringify({ version: 1, vars: [] }); + await writeFile(path, text, { mode: 0o600 }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "pending_env_migration" + ); + await expect(assertAutostartStartPolicy(opts)).rejects.toMatchObject({ + code: "E_CONFIG_INVALID", + message: + "Autostart skipped: pending env migration. Run hack doctor --migrate-env-config.", + detail: { reason: "pending_env_migration" }, + }); + expect(await readFile(path, "utf8")).toBe(text); + await rm(path); + } +}); diff --git a/tests/child-environment.test.ts b/tests/child-environment.test.ts index f9c3b6600..3aebc1dbe 100644 --- a/tests/child-environment.test.ts +++ b/tests/child-environment.test.ts @@ -264,8 +264,16 @@ test("login replay authority stops at tool boundaries and survives only delibera HACK_DAEMON_LOGIN_SERVICE: "1", KEEP: "yes", }; - expect(childEnvironment(input)).toEqual({ KEEP: "yes" }); - expect(hackReexecEnvironment(input)).toEqual({ + const child = childEnvironment(input); + expect(child).toMatchObject({ KEEP: "yes" }); + for (const key of [ + "HACK_AUTOSTART_LOGIN", + "HACK_AUTOSTART_PROJECT_ID", + "HACK_DAEMON_LOGIN_SERVICE", + ]) { + expect(child[key]).toBeUndefined(); + } + expect(hackReexecEnvironment(input)).toMatchObject({ HACK_AUTOSTART_LOGIN: "1", HACK_AUTOSTART_PROJECT_ID: "fixture-project", KEEP: "yes", @@ -278,3 +286,39 @@ test("login replay authority stops at tool boundaries and survives only delibera ).toEqual({}); expect(input.HACK_DAEMON_LOGIN_SERVICE).toBe("1"); }); + +test("login Git observations disable monitor hooks despite local config and inherited overrides", async () => { + const root = await fixture(); + const receipt = join(root, "git-monitor-ran"); + const monitor = join(root, "monitor.sh"); + await writeFile(monitor, `#!/bin/sh\nprintf ran > '${receipt}'\n`, { + mode: 0o700, + }); + const env = privateCliEnv({ root }); + const git = async (args: readonly string[], childEnv = env) => { + const child = Bun.spawn(["git", "-C", root, ...args], { + env: childEnv, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + return await child.exited; + }; + expect(await git(["init"])).toBe(0); + expect(await git(["config", "core.fsmonitor", monitor])).toBe(0); + const unattended = childEnvironment( + { ...env, HACK_AUTOSTART_LOGIN: "1" }, + { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.fsmonitor", + GIT_CONFIG_VALUE_0: monitor, + } + ); + expect(await git(["status", "--porcelain"], unattended)).toBe(0); + expect(await Bun.file(receipt).exists()).toBe(false); + expect(unattended.GIT_CONFIG_NOSYSTEM).toBe("1"); + expect(unattended.GIT_CONFIG_GLOBAL).toBe("/dev/null"); + expect(unattended.GIT_CONFIG_VALUE_1).toBe("/dev/null"); + expect(await git(["status", "--porcelain"])).toBe(0); + expect(await Bun.file(receipt).exists()).toBe(true); +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index 87b062ad4..f027584ca 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -1169,6 +1169,59 @@ test("repairing an executable path permits a first attempt in the same boot", as expect(starts.map((start) => start.id)).toEqual(["alpha"]); }); +test("manual-start policy refuses before engine access without claims or failed progress", async () => { + await seed(["alpha"]); + deps = { + ...deps, + projectRefusal: async () => "host_commands_require_manual_start", + }; + await run(); + expect(events).toEqual([]); + expect(starts).toEqual([]); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ?.lastAttemptBootId + ).toBeNull(); + expect((await readAutostartGlobal({ registryPath })).loginRun.failed).toEqual( + [] + ); + expect((await results())[0]?.result).toMatchObject({ + status: "skipped", + reason: "host_commands_require_manual_start", + }); + deps = { ...deps, projectRefusal: async () => null }; + await run(); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); +}); + +test("late child manual-start refusal releases only its unstarted claim and remains neutral", async () => { + await seed(["alpha"]); + deps = { + ...deps, + start: async () => ({ + status: "skipped", + reason: "image_requires_manual_start", + }), + }; + await run(); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ?.lastAttemptBootId + ).toBeNull(); + expect((await readAutostartGlobal({ registryPath })).loginRun.failed).toEqual( + [] + ); + deps = { + ...deps, + start: async () => { + starts.push({ id: "alpha", at: clock }); + return { status: "started", reason: null }; + }, + }; + await run(); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); +}); + test("unsafe Docker context selection refuses before engine access without consuming a claim", async () => { await seed(["alpha"]); await mkdir(join(root, ".docker"), { mode: 0o700 }); diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md index f283e3aca..e2bd69ea2 100644 --- a/tests/models/tla/autostart-start-stop/COVERAGE.md +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -49,6 +49,14 @@ The outer login deadline allows five seconds for the inner two-second escalation and bounded settlement. Killable processes and a functioning process census are assumptions; SIGKILL of the CLI, host failure, and an engine request already accepted outside its client group remain outside this finite model. +Unattended Compose now checks intent before preparation and before starting +containers, and never builds, pulls or executes configured host commands. +`runtime-backend-unattended.test.ts` maps a pause before preparation to zero +subprocess calls; `autostart-host-boundary.test.ts` checks the actual host runners. +Closed manual-start refusals can release a boot claim through a boot/branch +compare-and-set before any start. This model does not cover that registry ledger; +`project-autostart.test.ts` covers stale, changed-target and real-failure refusal +cases, and daemon tests cover neutral progress and same-boot recovery. Multiple projects, multiple login attempts, reboot, upgrades, global pause, retries, network prompts, native backends and real login-service behavior are outside this model. Component model success does not qualify installed binaries or real login. diff --git a/tests/project-autostart.test.ts b/tests/project-autostart.test.ts index e924669ad..42a342e16 100644 --- a/tests/project-autostart.test.ts +++ b/tests/project-autostart.test.ts @@ -1128,6 +1128,191 @@ test("a refusal without a boot claim preserves admission and cannot bypass a pau expect((await read())?.autostart).toBeUndefined(); }); +test("closed manual-start refusals atomically record a skip and release an unstarted claim", async () => { + const reasons = [ + "host_commands_require_manual_start", + "image_build_requires_manual_start", + "image_requires_manual_start", + "command_provider_requires_manual_start", + "compose_inputs_require_manual_start", + "env_file_requires_manual_start", + ]; + await setProjectAutostart({ id: "alpha", registryPath }); + for (const reason of reasons) { + await claimProjectAutostart({ + id: "alpha", + bootId: "boot-a", + registryPath, + }); + const result = { + status: "skipped" as const, + reason, + at: "2026-01-01T00:00:00.000Z", + }; + expect( + await recordProjectAutostartResult({ + id: "alpha", + bootId: "boot-a", + expectedBranch: null, + releaseUnstartedClaim: true, + result, + registryPath, + }) + ).toBe(true); + expect((await read())?.autostart).toMatchObject({ + lastAttemptBootId: null, + lastResult: result, + paused: false, + }); + } + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).not.toBeNull(); +}); + +test("failure, unknown reason and missing release intent or branch never release boot admission", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + const cases = [ + { + status: "failed" as const, + reason: "host_commands_require_manual_start", + releaseUnstartedClaim: true, + expectedBranch: null, + }, + { + status: "started" as const, + reason: "host_commands_require_manual_start", + releaseUnstartedClaim: true, + expectedBranch: null, + }, + { + status: "skipped" as const, + reason: "unknown_manual_reason", + releaseUnstartedClaim: true, + expectedBranch: null, + }, + { + status: "skipped" as const, + reason: "checkout_untrusted", + releaseUnstartedClaim: true, + expectedBranch: null, + }, + { + status: "skipped" as const, + reason: "host_commands_require_manual_start", + releaseUnstartedClaim: false, + expectedBranch: null, + }, + { + status: "skipped" as const, + reason: "host_commands_require_manual_start", + releaseUnstartedClaim: true, + expectedBranch: undefined, + }, + ]; + for (const item of cases) { + expect( + await recordProjectAutostartResult({ + id: "alpha", + bootId: "boot-a", + releaseUnstartedClaim: item.releaseUnstartedClaim, + expectedBranch: item.expectedBranch, + result: { + status: item.status, + reason: item.reason, + at: "2026-01-01T00:00:00.000Z", + }, + registryPath, + }) + ).toBe(true); + expect((await read())?.autostart?.lastAttemptBootId).toBe("boot-a"); + } + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); +}); + +test("manual refusal release cannot alter a new boot, changed branch or removed intent", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + const refusal = { + id: "alpha", + bootId: "boot-a", + expectedBranch: null, + releaseUnstartedClaim: true, + result: { + status: "skipped" as const, + reason: "image_requires_manual_start", + at: "2026-01-01T00:00:00.000Z", + }, + registryPath, + }; + await claimProjectAutostart({ id: "alpha", bootId: "boot-b", registryPath }); + expect(await recordProjectAutostartResult(refusal)).toBe(false); + expect((await read())?.autostart?.lastAttemptBootId).toBe("boot-b"); + await setProjectAutostart({ id: "alpha", branch: "preview", registryPath }); + expect( + await recordProjectAutostartResult({ ...refusal, bootId: "boot-b" }) + ).toBe(false); + expect((await read())?.autostart).toMatchObject({ + branch: "preview", + lastAttemptBootId: "boot-b", + lastResult: null, + }); + await removeProjectAutostart({ id: "alpha", registryPath }); + expect( + await recordProjectAutostartResult({ + ...refusal, + bootId: "boot-b", + expectedBranch: "preview", + }) + ).toBe(false); + expect((await read())?.autostart).toBeUndefined(); +}); + +test("manual refusal release preserves pauses and respects wanted-only CAS", async () => { + await setProjectAutostart({ id: "alpha", registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + const refusal = { + id: "alpha", + bootId: "boot-a", + expectedBranch: null, + releaseUnstartedClaim: true, + result: { + status: "skipped" as const, + reason: "compose_inputs_require_manual_start", + at: "2026-01-01T00:00:00.000Z", + }, + registryPath, + }; + await setProjectAutostartPaused({ id: "alpha", paused: true, registryPath }); + expect( + await recordProjectAutostartResult({ ...refusal, onlyIfWanted: true }) + ).toBe(false); + expect((await read())?.autostart).toMatchObject({ + paused: true, + lastAttemptBootId: "boot-a", + lastResult: null, + }); + expect(await recordProjectAutostartResult(refusal)).toBe(true); + expect((await read())?.autostart).toMatchObject({ + paused: true, + lastAttemptBootId: null, + lastResult: refusal.result, + }); + expect( + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }) + ).toBeNull(); + await setProjectAutostartPaused({ id: "alpha", paused: false, registryPath }); + await claimProjectAutostart({ id: "alpha", bootId: "boot-a", registryPath }); + await setAutostartGlobalPaused({ paused: true, registryPath }); + expect( + await recordProjectAutostartResult({ ...refusal, onlyIfWanted: true }) + ).toBe(false); + expect((await read())?.autostart?.lastAttemptBootId).toBe("boot-a"); +}); + test("ordinary registration replaces an unmarked registry symlink without changing its target", async () => { const project = await createTrustProject(); const target = join(root, "linked-legacy-registry.json"); diff --git a/tests/runtime-backend-unattended.test.ts b/tests/runtime-backend-unattended.test.ts new file mode 100644 index 000000000..51e2813ac --- /dev/null +++ b/tests/runtime-backend-unattended.test.ts @@ -0,0 +1,58 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { composeRuntimeBackend } from "../src/backends/runtime-backend.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const roots: string[] = []; +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +test("real unattended Compose subprocess cannot build or pull to recover a missing image", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-unattended-compose-")); + roots.push(root); + const bin = join(root, "bin"); + await mkdir(bin); + const compose = join(root, "compose.yml"); + await writeFile( + compose, + "services:\n app:\n image: example.invalid/locally-prepared\n build: .\n" + ); + const called = join(root, "called.json"); + const unsafe = join(root, "build-or-pull-ran"); + await writeFile( + join(bin, "docker"), + `#!${process.execPath} +const args=process.argv.slice(2); +if(args.includes("config") && args.includes("--images")) { process.stdout.write("example.invalid/locally-prepared:latest\\n");process.exit(0); } +if(args[0]==="image" && args[1]==="inspect") { process.stderr.write("Error response from daemon: No such image: example.invalid/locally-prepared:latest\\n");process.exit(1); } +await Bun.write(${JSON.stringify(called)},JSON.stringify(args)); +const pull=args.indexOf("--pull"); const dotenv=args.indexOf("--env-file"); +if(!args.includes("--no-build")||pull<0||args[pull+1]!=="never"||dotenv<0||args[dotenv+1]!=="/dev/null") { + await Bun.write(${JSON.stringify(unsafe)},"unsafe fallback");process.exit(0); +} +process.exit(1); +`, + { mode: 0o755 } + ); + await expect( + composeRuntimeBackend.up({ + composeFiles: [compose], + cwd: root, + env: privateCliEnv({ root, env: { PATH: bin } }), + detach: true, + unattended: true, + imageTimeoutMs: 1000, + startupTimeoutMs: 1000, + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "image_requires_manual_start" }, + }); + expect(await Bun.file(unsafe).exists()).toBe(false); + expect(await Bun.file(called).exists()).toBe(false); +}); diff --git a/tests/runtime-backend.test.ts b/tests/runtime-backend.test.ts index b313010ac..b94c77721 100644 --- a/tests/runtime-backend.test.ts +++ b/tests/runtime-backend.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; const runCalls: string[][] = []; +const runResults: number[] = []; const runOpts: { stdout?: string; timeoutMs?: number; @@ -13,6 +14,7 @@ const runOpts: { settleProcessGroupOnCancel?: boolean; }[] = []; const execCalls: string[][] = []; +const execResults: { exitCode: number; stdout: string; stderr: string }[] = []; const shellMock = await registerScopedModuleMock({ importerPath: import.meta.path, @@ -20,7 +22,7 @@ const shellMock = await registerScopedModuleMock({ overrides: { exec: async (cmd: readonly string[]) => { execCalls.push([...cmd]); - return { exitCode: 0, stdout: "", stderr: "" }; + return execResults.shift() ?? { exitCode: 0, stdout: "", stderr: "" }; }, execOrThrow: async (cmd: readonly string[]) => { execCalls.push([...cmd]); @@ -42,7 +44,7 @@ const shellMock = await registerScopedModuleMock({ forwardSignals: opts.forwardSignals, settleProcessGroupOnCancel: opts.settleProcessGroupOnCancel, }); - return 0; + return runResults.shift() ?? 0; }, findExecutableInPath: () => "/usr/bin/docker", CommandError: class CommandError extends Error {}, @@ -79,8 +81,10 @@ beforeAll(() => { beforeEach(() => { runCalls.length = 0; + runResults.length = 0; runOpts.length = 0; execCalls.length = 0; + execResults.length = 0; }); afterAll(() => { @@ -493,3 +497,171 @@ test("automatic bootstrap preserves output and forwards cancellation to its boun settleProcessGroupOnCancel: undefined, }); }); + +test("unattended detached startup rechecks intent before preparation and disables builds, pulls and implicit dotenv", async () => { + const backend = await loadComposeRuntimeBackend(); + const observations: number[] = []; + const code = await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + beforeStart: async () => { + observations.push(runCalls.length); + }, + }); + expect(code).toBe(0); + expect(observations).toEqual([0, 0, 1]); + expect(runCalls).toEqual([ + [ + "docker", + "compose", + "-f", + "compose.yml", + "--env-file", + "/dev/null", + "up", + "--no-start", + "--no-build", + "--pull", + "never", + ], + [ + "docker", + "compose", + "-f", + "compose.yml", + "--env-file", + "/dev/null", + "up", + "-d", + "--no-build", + "--pull", + "never", + ], + ]); +}); + +test("withdrawn unattended intent refuses before create, pull or build phases", async () => { + const backend = await loadComposeRuntimeBackend(); + await expect( + backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + beforeStart: async () => { + throw new Error("paused before preparation"); + }, + }) + ).rejects.toThrow("paused before preparation"); + expect(runCalls).toEqual([]); +}); + +test("only an exact missing image diagnostic authorizes an unattended manual-start refusal", async () => { + const backend = await loadComposeRuntimeBackend(); + execResults.push( + { exitCode: 0, stdout: "example.invalid/app\n", stderr: "" }, + { + exitCode: 1, + stdout: "", + stderr: + "Error response from daemon: No such image: example.invalid/app:latest\n", + } + ); + await expect( + backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "image_requires_manual_start" }, + }); + expect(runCalls).toEqual([]); + expect(execCalls[1]).toEqual([ + "docker", + "image", + "inspect", + "--format", + "{{.Id}}", + "example.invalid/app:latest", + ]); +}); + +test("images present plus a failed prepare phase remains an ordinary claimed failure", async () => { + const backend = await loadComposeRuntimeBackend(); + execResults.push( + { exitCode: 0, stdout: "example.invalid/app\n", stderr: "" }, + { exitCode: 0, stdout: `sha256:${"a".repeat(64)}\n`, stderr: "" } + ); + runResults.push(17); + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).toBe(17); + expect(runCalls).toHaveLength(1); +}); + +for (const diagnostic of [ + "Cannot connect to the Docker daemon", + "Error response from daemon: unauthorized", + "Error response from daemon: No such image: another.invalid/image:latest", + "Error response from daemon: No such image: example.invalid/app:latest\nextra diagnostic", +]) { + test(`image inspection refuses unknown errors without a neutral missing-image result: ${diagnostic.split("\n")[0]}`, async () => { + const backend = await loadComposeRuntimeBackend(); + execResults.push( + { exitCode: 0, stdout: "example.invalid/app\n", stderr: "" }, + { exitCode: 1, stdout: "", stderr: diagnostic } + ); + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).toBe(1); + expect(runCalls).toEqual([]); + }); +} + +for (const cancellation of [124, 130, 143]) { + test(`unattended preparation preserves cancellation exit ${cancellation} without a start phase`, async () => { + const backend = await loadComposeRuntimeBackend(); + runResults.push(cancellation); + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).toBe(cancellation); + expect(runCalls).toHaveLength(1); + expect(runOpts[0]?.settleProcessGroupOnCancel).toBe(true); + }); +} + +test("attached unattended startup refuses before invoking Compose", async () => { + const backend = await loadComposeRuntimeBackend(); + await expect( + backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: false, + unattended: true, + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "unattended_requires_detached" }, + }); + expect(runCalls).toEqual([]); +}); diff --git a/tests/shell-bounded-capture.test.ts b/tests/shell-bounded-capture.test.ts new file mode 100644 index 000000000..9ccd8e6f7 --- /dev/null +++ b/tests/shell-bounded-capture.test.ts @@ -0,0 +1,126 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { exec } from "../src/lib/shell.ts"; +import { privateCliEnv, privateCliRunEnv } from "./helpers/private-cli-env.ts"; + +const roots: string[] = []; +const children: ReturnType[] = []; +afterEach(async () => { + for (const child of children.splice(0)) { + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "hack-bounded-capture-")); + roots.push(root); + return root; +} + +test("bounded capture drains stdout and stderr concurrently without losing a successful result", async () => { + const root = await fixture(); + const result = await exec( + [ + process.execPath, + "-e", + 'process.stdout.write("o".repeat(32000));process.stderr.write("e".repeat(2000));', + ], + { + cwd: root, + ...privateCliRunEnv({ root }), + stdin: "ignore", + timeoutMs: 2000, + maxStdoutBytes: 65_536, + maxStderrBytes: 4096, + forwardSignals: true, + } + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toHaveLength(32_000); + expect(result.stderr).toHaveLength(2000); +}); + +test("stderr overflow kills and settles the owned capture rather than returning an image-like partial diagnostic", async () => { + const root = await fixture(); + const result = await exec( + [ + process.execPath, + "-e", + 'process.stdout.write("o".repeat(32000));process.stderr.write("e".repeat(32000));setInterval(()=>{},1000);', + ], + { + cwd: root, + ...privateCliRunEnv({ root }), + stdin: "ignore", + timeoutMs: 3000, + maxStdoutBytes: 65_536, + maxStderrBytes: 4096, + forwardSignals: true, + } + ); + expect(result).toEqual({ exitCode: 125, stdout: "", stderr: "" }); +}); + +test("bounded read-only capture forwards TERM and settles resistant group members before returning", async () => { + const root = await fixture(); + const ready = join(root, "ready"); + const receipt = join(root, "receipt.json"); + const driver = join(root, "driver.ts"); + const script = `trap '' TERM INT; (trap '' TERM INT; sleep 30) & printf '%s %s\\n' "$$" "$!" > "$1"; wait`; + await writeFile( + driver, + `import {exec} from ${JSON.stringify(resolve("src/lib/shell.ts"))};const result=await exec(["/bin/sh","-c",${JSON.stringify(script)},"sh",${JSON.stringify(ready)}],{stdin:"ignore",timeoutMs:5000,maxStdoutBytes:65536,maxStderrBytes:4096,forwardSignals:true});await Bun.write(${JSON.stringify(receipt)},JSON.stringify(result));` + ); + const child = Bun.spawn([process.execPath, driver], { + cwd: root, + env: privateCliEnv({ root }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + detached: true, + }); + children.push(child); + const output = Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + const deadline = Date.now() + 3000; + while ( + !(await Bun.file(ready).exists()) && + Date.now() < deadline && + child.exitCode === null + ) { + await Bun.sleep(10); + } + expect(await Bun.file(ready).exists()).toBe(true); + const pids = (await readFile(ready, "utf8")).trim().split(" ").map(Number); + const started = Date.now(); + child.kill("SIGTERM"); + const [stdout, stderr, code] = await output; + expect(code, stdout + stderr).toBe(0); + expect(Date.now() - started).toBeLessThan(4500); + expect(await Bun.file(receipt).json()).toEqual({ + exitCode: 143, + stdout: "", + stderr: "", + }); + for (const pid of pids) { + const probe = Bun.spawn(["/bin/ps", "-o", "stat=", "-p", String(pid)], { + env: { PATH: "/usr/bin:/bin", LC_ALL: "C" }, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }); + const state = (await new Response(probe.stdout).text()).trim(); + await probe.exited; + expect(state === "" || state.startsWith("Z")).toBe(true); + } +}, 12_000); From 44154d5c84c3cd7af4603a87a599b0cbd409d0ae Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:54:56 +0000 Subject: [PATCH 18/44] fix(autostart): reject unsafe effective Compose mounts --- docs/always-on-projects.md | 26 +- docs/versions/v5/getting-started.md | 2 + src/backends/compose-unattended-preflight.ts | 377 ++++++++++++++++++ src/backends/runtime-backend.ts | 148 +++---- src/commands/project.ts | 5 + src/lib/autostart-compose-mounts.ts | 127 ++++++ src/lib/autostart-start-policy.ts | 4 +- src/lib/autostart-trust-mounts.ts | 144 +++++++ tests/autostart-checkout-trust.test.ts | 23 +- tests/autostart-start-policy.test.ts | 86 ++++ tests/autostart-trust-mounts.test.ts | 189 +++++++++ tests/child-environment.test.ts | 10 +- .../tla/autostart-start-stop/COVERAGE.md | 8 + tests/runtime-backend-unattended.test.ts | 90 ++++- tests/runtime-backend.test.ts | 326 ++++++++++++++- 15 files changed, 1458 insertions(+), 107 deletions(-) create mode 100644 src/backends/compose-unattended-preflight.ts create mode 100644 src/lib/autostart-compose-mounts.ts create mode 100644 src/lib/autostart-trust-mounts.ts create mode 100644 tests/autostart-trust-mounts.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 7c2b48183..269fbb67d 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -108,6 +108,27 @@ These manual-start requirements produce a closed skip and consume no boot claim or failure entry. Doctor reports the reason. Fix the unsupported prerequisite or start manually; ordinary manual starts retain their behavior. +Host-file bind mounts, file-backed configs/secrets and indirect `volumes_from` +sources also require manual startup, including source-code mounts used by many +development projects. Anonymous volumes, tmpfs and default local named volumes +remain supported. The resolved model, including Hack's generated overrides, is +checked before effects. Existing named volumes must have no driver options and +must carry the matching Compose project/volume labels; aliases, external volumes +and custom drivers require manual startup. Storage is checked again before +creation and before start. + +The only host-file exception is Hack's generated read-only TLS certificate mounts +at `/etc/hack/ca/`. Their fixed installed paths must be regular, publicly readable +CA-only PEM files with trusted ownership, safe parent directories and no writable +group/other permissions. Bounded no-follow reads validate the content and repeat +before effects. This does not change certificates or permissions. Prepared images +and engine-managed data remain trusted inputs; this policy is not a container +privilege sandbox and does not remove configured devices or privileged access. +TLS checks observe current permissions and cannot revoke an older open writer. +If a trust file was formerly writable, preserve it outside the active PKI and +reinstall reviewed trust material manually; in-place `chmod` does not revoke +that descriptor. Automatic startup never repairs or republishes trust material. + Already-running eligible instances are adopted. An unknown runtime state is skipped rather than taken as proof that the instance is down. @@ -148,8 +169,9 @@ saved registry pathname still matches. Startup configuration, Compose and enviro input files and their symlink targets also require trusted ownership and modes; generated JSON/YAML/env inputs under `.hack/.internal` and `.hack/.branch` are included. protected directories alone do not make a writable lifecycle configuration safe. -Normal owner Git updates remain allowed. This is not a recursive audit of app -source mounted into containers. Unattended host hooks and image builds are refused +Normal owner Git updates remain allowed. Application code inside prepared images +and engine-managed volumes is not recursively audited. Unattended host hooks, +host-file execution inputs and image builds are refused rather than relying on filename parsing to authenticate arbitrary shell code. Login's Git identity, tracking and ignore probes disable system/global Git configuration, filesystem-monitor callbacks and hooks. Managed env layers cannot diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 501797d46..65db7ac02 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -72,6 +72,8 @@ permission refusals, including app-bundle and Homebrew paths on macOS. This slice starts only eligible Compose projects with images already available locally. Hooks, host helpers, command providers and image builds require manual `hack up`; doctor explains those manual-start requirements. +Host-file/source-code bind mounts also require manual starts. Verified default +engine volumes and Hack's authenticated read-only TLS mounts remain supported. Expect the declared services in `ps` and the app's local URL from `open`. If a project has several URLs, choose the app's route. A worker-only project may diff --git a/src/backends/compose-unattended-preflight.ts b/src/backends/compose-unattended-preflight.ts new file mode 100644 index 000000000..6b9d63f26 --- /dev/null +++ b/src/backends/compose-unattended-preflight.ts @@ -0,0 +1,377 @@ +import { HackCliError } from "../lib/cli-result.ts"; +import { isRecord } from "../lib/guards.ts"; +import { exec } from "../lib/shell.ts"; +import type { RuntimeUpOptions } from "./runtime-backend.ts"; + +const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@+[\]-]{0,511}$/; +const IMAGE_ID = /^sha256:[a-f0-9]{64}\s*$/; +const VOLUME_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,255}$/; +const VOLUME_FORMAT = + '{"Name":{{json .Name}},"Driver":{{json .Driver}},"Options":{{json .Options}},"Labels":{{json .Labels}}}'; +export interface UnattendedTrustedBind { + readonly source: string; + readonly target: string; + readonly readOnly: true; +} +export interface UnattendedNamedVolume { + readonly name: string; + readonly key: string; + readonly project: string; +} +interface ModelSources { + readonly binds: readonly UnattendedTrustedBind[]; + readonly images: readonly string[]; + readonly volumes: readonly UnattendedNamedVolume[]; +} +export interface UnattendedInspection { + readonly binds: readonly UnattendedTrustedBind[]; + readonly exitCode: number; + readonly volumes: readonly UnattendedNamedVolume[]; +} + +function refuseSources(): never { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Autostart cannot prove that the resolved project uses only ordinary engine storage. Run hack up manually.", + detail: { reason: "compose_inputs_require_manual_start" }, + }); +} +function emptyOptions(value: unknown): boolean { + return value === null || (isRecord(value) && Object.keys(value).length === 0); +} +function json(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function namedVolume( + source: string, + project: unknown, + definitions: Record +): UnattendedNamedVolume { + if (typeof project !== "string") { + refuseSources(); + } + const definition = definitions[source]; + const name = `${project}_${source}`; + if ( + !isRecord(definition) || + definition.name !== name || + !VOLUME_NAME.test(name) + ) { + refuseSources(); + } + if ( + (definition.driver !== undefined && definition.driver !== "local") || + (definition.external !== undefined && definition.external !== false) || + (definition.driver_opts !== undefined && + !emptyOptions(definition.driver_opts)) + ) { + refuseSources(); + } + return { name, key: source, project }; +} +function authenticatedBind( + mount: Record, + allowed: readonly UnattendedTrustedBind[] +): UnattendedTrustedBind { + const trusted = allowed.find( + (entry) => + entry.source === mount.source && + entry.target === mount.target && + entry.readOnly === true && + mount.read_only === true + ); + if ( + !trusted || + (mount.bind !== undefined && + (!isRecord(mount.bind) || + Object.keys(mount.bind).some((key) => key !== "create_host_path"))) || + mount.volume !== undefined + ) { + refuseSources(); + } + return trusted; +} + +function serviceVolumes( + service: Record, + definitions: Record, + project: unknown, + trustedBinds: readonly UnattendedTrustedBind[], + usedBinds: Map +): readonly UnattendedNamedVolume[] { + for (const key of [ + "configs", + "secrets", + "volumes_from", + "label_file", + "credential_spec", + ]) { + if (service[key] !== undefined) { + refuseSources(); + } + } + if (service.volumes === undefined) { + return []; + } + if (!Array.isArray(service.volumes)) { + refuseSources(); + } + const volumes: UnattendedNamedVolume[] = []; + for (const mount of service.volumes) { + if (!isRecord(mount)) { + refuseSources(); + } + if (mount.type === "bind") { + const trusted = authenticatedBind(mount, trustedBinds); + usedBinds.set(trusted.target, trusted); + continue; + } + if (mount.type === "tmpfs" && mount.source === undefined) { + continue; + } + if (mount.type !== "volume") { + refuseSources(); + } + if (mount.source === undefined) { + continue; + } + if (typeof mount.source !== "string") { + refuseSources(); + } + volumes.push(namedVolume(mount.source, project, definitions)); + } + return volumes; +} +/** Resolved models may contain sensitive internals; never log or persist any part of them. */ +function modelSources( + value: unknown, + trustedBinds: readonly UnattendedTrustedBind[] +): ModelSources | undefined { + if (!(isRecord(value) && isRecord(value.services))) { + return undefined; + } + if ( + (isRecord(value.configs) && Object.keys(value.configs).length > 0) || + (isRecord(value.secrets) && Object.keys(value.secrets).length > 0) + ) { + refuseSources(); + } + const definitions = isRecord(value.volumes) ? value.volumes : {}; + const binds = new Map(); + const images = new Set(); + const volumes = new Map(); + for (const service of Object.values(value.services)) { + if (!isRecord(service) || typeof service.image !== "string") { + return undefined; + } + images.add(service.image); + for (const volume of serviceVolumes( + service, + definitions, + value.name, + trustedBinds, + binds + )) { + volumes.set(volume.name, volume); + } + } + if ( + images.size > 256 || + volumes.size > 256 || + [...images].some((image) => !IMAGE_REFERENCE.test(image)) + ) { + return undefined; + } + return { + images: [...images], + volumes: [...volumes.values()], + binds: [...binds.values()], + }; +} +function probeFor(opts: RuntimeUpOptions, timeoutMs: number) { + const deadline = Date.now() + Math.min(timeoutMs, 15_000); + return async (args: readonly string[], maxStdoutBytes = 65_536) => { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return { exitCode: 124, stdout: "", stderr: "" }; + } + return await exec(args, { + cwd: opts.cwd, + env: opts.env, + stdin: "ignore", + timeoutMs: remaining, + maxStdoutBytes, + maxStderrBytes: 4096, + forwardSignals: true, + }); + }; +} +type Probe = ReturnType; +async function imagePresent(image: string, probe: Probe): Promise { + const reference = + image.includes("@") || image.slice(image.lastIndexOf("/") + 1).includes(":") + ? image + : `${image}:latest`; + const result = await probe([ + "docker", + "image", + "inspect", + "--format", + "{{.Id}}", + reference, + ]); + if (result.exitCode === 0 && IMAGE_ID.test(result.stdout)) { + return 0; + } + if ( + result.exitCode === 1 && + result.stdout.trim() === "" && + result.stderr.trim() === + `Error response from daemon: No such image: ${reference}` + ) { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Autostart requires prepared local images. Run hack up manually before retrying.", + detail: { reason: "image_requires_manual_start" }, + }); + } + return result.exitCode || 1; +} +function resolvedModelHasBinds(value: unknown): boolean { + if (!(isRecord(value) && isRecord(value.services))) { + return false; + } + return Object.values(value.services).some( + (service) => + isRecord(service) && + Array.isArray(service.volumes) && + service.volumes.some((mount) => isRecord(mount) && mount.type === "bind") + ); +} + +/** Model and image probes run after the caller's unattended eligibility and intent guard. */ +export async function inspectUnattendedInputs( + opts: RuntimeUpOptions, + composeArgs: readonly string[], + timeoutMs: number +): Promise { + try { + const probe = probeFor(opts, timeoutMs); + const config = await probe( + [...composeArgs, "config", "--format", "json"], + 1024 * 1024 + ); + if (config.exitCode !== 0) { + return { exitCode: config.exitCode, volumes: [], binds: [] }; + } + const model = json(config.stdout); + const authenticated = resolvedModelHasBinds(model) + ? ((await opts.unattendedTrustedBinds?.()) ?? []) + : []; + const sources = modelSources(model, authenticated); + if (!sources) { + return { exitCode: 1, volumes: [], binds: [] }; + } + for (const image of sources.images) { + const code = await imagePresent(image, probe); + if (code !== 0) { + return { exitCode: code, volumes: [], binds: [] }; + } + } + return { exitCode: 0, volumes: sources.volumes, binds: sources.binds }; + } catch (error: unknown) { + if (error instanceof HackCliError) { + throw error; + } + return { exitCode: 1, volumes: [], binds: [] }; + } +} +async function volumeSafe( + volume: UnattendedNamedVolume, + probe: Probe +): Promise { + const result = await probe([ + "docker", + "volume", + "inspect", + "--format", + VOLUME_FORMAT, + volume.name, + ]); + if (result.exitCode !== 0) { + const missing = [ + `Error response from daemon: get ${volume.name}: no such volume`, + `Error response from daemon: No such volume: ${volume.name}`, + ]; + return result.exitCode === 1 && + result.stdout.trim() === "" && + missing.includes(result.stderr.trim()) + ? 0 + : result.exitCode || 1; + } + const value = json(result.stdout); + if ( + !isRecord(value) || + value.Name !== volume.name || + value.Options === undefined || + typeof value.Driver !== "string" + ) { + return 1; + } + if ( + value.Driver !== "local" || + !emptyOptions(value.Options) || + !isRecord(value.Labels) || + value.Labels["com.docker.compose.project"] !== volume.project || + value.Labels["com.docker.compose.volume"] !== volume.key + ) { + refuseSources(); + } + return 0; +} +/** Recheck existing engine storage as late as possible, before create and again before start. */ +export async function inspectUnattendedStorage( + opts: RuntimeUpOptions, + volumes: readonly UnattendedNamedVolume[], + binds: readonly UnattendedTrustedBind[], + timeoutMs: number +): Promise { + try { + if (binds.length > 0) { + const authenticated = (await opts.unattendedTrustedBinds?.()) ?? []; + if ( + binds.some( + (used) => + !authenticated.some( + (entry) => + entry.source === used.source && + entry.target === used.target && + entry.readOnly === true + ) + ) + ) { + refuseSources(); + } + } + const probe = probeFor(opts, timeoutMs); + for (const volume of volumes) { + const code = await volumeSafe(volume, probe); + if (code !== 0) { + return code; + } + } + return 0; + } catch (error: unknown) { + if (error instanceof HackCliError) { + throw error; + } + return 1; + } +} diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index f50706cb5..ceaee82a7 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -10,6 +10,12 @@ import { withDependencyStartupProgress } from "../lib/dependency-startup-progres import { exec, run } from "../lib/shell.ts"; import { readLinesFromStream } from "../ui/lines.ts"; import { createStructuredLogGrouper } from "../ui/log-group.ts"; +import { + inspectUnattendedInputs, + inspectUnattendedStorage, + type UnattendedNamedVolume, + type UnattendedTrustedBind, +} from "./compose-unattended-preflight.ts"; export type RuntimeBackendName = "compose"; const DEFAULT_COMPOSE_INSPECTION_TIMEOUT_MS = 15_000; @@ -40,6 +46,10 @@ export interface RuntimeBaseOptions { export interface RuntimeUpOptions extends RuntimeBaseOptions { /** Unattended startup may only reuse local images, never build, pull or load an implicit .env. */ readonly unattended?: boolean; + /** Re-authenticated machine-owned TLS files only; authored binds remain ineligible. */ + readonly unattendedTrustedBinds?: () => Promise< + readonly UnattendedTrustedBind[] + >; readonly dependencyProgress?: { readonly project: string; readonly services: readonly string[]; @@ -154,89 +164,43 @@ export function buildComposeExecCommand(opts: { ]; } -const UNATTENDED_IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@+[\]-]{0,511}$/; -const LOCAL_IMAGE_ID = /^sha256:[a-f0-9]{64}\s*$/; - -/** Only an exact daemon missing-image diagnostic authorizes a neutral manual-start refusal. */ -async function inspectUnattendedImages( +async function guardStartupStorage( opts: RuntimeUpOptions, - composeArgs: readonly string[], + volumes: readonly UnattendedNamedVolume[], + binds: readonly UnattendedTrustedBind[], timeoutMs: number ): Promise { - const deadline = Date.now() + Math.min(timeoutMs, 15_000); - const probe = async (args: readonly string[]) => { - const remaining = deadline - Date.now(); - if (remaining <= 0) { - return { exitCode: 124, stdout: "", stderr: "" }; - } - return await exec(args, { - cwd: opts.cwd, - env: opts.env, - stdin: "ignore", - timeoutMs: remaining, - maxStdoutBytes: 65_536, - maxStderrBytes: 4096, - forwardSignals: true, - }); - }; - try { - const config = await probe([...composeArgs, "config", "--images"]); - if (config.exitCode !== 0) { - return config.exitCode; - } - const images = [ - ...new Set( - config.stdout - .split("\n") - .map((image) => image.trim()) - .filter(Boolean) - ), - ]; - if ( - images.length > 256 || - images.some((image) => !UNATTENDED_IMAGE_REFERENCE.test(image)) - ) { - return 1; - } - for (const image of images) { - const reference = - image.includes("@") || - image.slice(image.lastIndexOf("/") + 1).includes(":") - ? image - : `${image}:latest`; - const result = await probe([ - "docker", - "image", - "inspect", - "--format", - "{{.Id}}", - reference, - ]); - if (result.exitCode === 0 && LOCAL_IMAGE_ID.test(result.stdout)) { - continue; - } - if ( - result.exitCode === 1 && - result.stdout.trim() === "" && - result.stderr.trim() === - `Error response from daemon: No such image: ${reference}` - ) { - throw new HackCliError({ - code: "E_PERMISSION", - message: - "Autostart requires prepared local images. Run hack up manually before retrying.", - detail: { reason: "image_requires_manual_start" }, - }); - } - return result.exitCode || 1; - } + await opts.beforeStart?.(); + if (!opts.unattended || (volumes.length === 0 && binds.length === 0)) { return 0; - } catch (error: unknown) { - if (error instanceof HackCliError) { - throw error; - } - return 1; } + const code = await inspectUnattendedStorage(opts, volumes, binds, timeoutMs); + if (code === 0) { + await opts.beforeStart?.(); + } + return code; +} + +async function prepareUnattendedStartup( + opts: RuntimeUpOptions, + composeArgs: readonly string[], + timeoutMs: number +) { + if (!opts.unattended) { + return { exitCode: 0, volumes: [], binds: [] }; + } + await opts.beforeStart?.(); + const inputs = await inspectUnattendedInputs(opts, composeArgs, timeoutMs); + if (inputs.exitCode !== 0) { + return inputs; + } + const storage = await guardStartupStorage( + opts, + inputs.volumes, + inputs.binds, + timeoutMs + ); + return { ...inputs, exitCode: storage }; } export const composeRuntimeBackend: RuntimeBackend = { @@ -282,17 +246,13 @@ export const composeRuntimeBackend: RuntimeBackend = { // start invocation then only starts the prepared containers and waits on // Compose dependency conditions; --no-build keeps it from rebuilding. const launch = async () => { - if (opts.unattended) { - await opts.beforeStart?.(); - const images = await inspectUnattendedImages( - opts, - composeArgs, - imageTimeoutMs - ); - if (images !== 0) { - return images; - } - await opts.beforeStart?.(); + const inputs = await prepareUnattendedStartup( + opts, + composeArgs, + imageTimeoutMs + ); + if (inputs.exitCode !== 0) { + return inputs.exitCode; } opts.onStartupPhase?.("images"); const prepared = await runPhase( @@ -310,7 +270,15 @@ export const composeRuntimeBackend: RuntimeBackend = { return prepared; } opts.onStartupPhase?.("start"); - await opts.beforeStart?.(); + const storage = await guardStartupStorage( + opts, + inputs.volumes, + inputs.binds, + startupTimeoutMs + ); + if (storage !== 0) { + return storage; + } return await runPhase( [ ...composeArgs, diff --git a/src/commands/project.ts b/src/commands/project.ts index 39526126b..272e07ca5 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,4 +1,5 @@ import { assertAutostartHostCommandsAllowed } from "../lib/autostart-refusal.ts"; +import { resolveAutostartTrustedTlsBinds } from "../lib/autostart-trust-mounts.ts"; import { reportLooseEnvLayerModes } from "../lib/env-layer-modes.ts"; import { createLifecycleProcessController, @@ -6988,6 +6989,10 @@ async function runUpCommand({ composeRuntimeBackend.up({ unattended: process.env.HACK_AUTOSTART_LOGIN === "1" ? true : undefined, + unattendedTrustedBinds: + process.env.HACK_AUTOSTART_LOGIN === "1" + ? resolveAutostartTrustedTlsBinds + : undefined, beforeStart: async () => { await assertLoginAutostartCurrent(branch, project.projectRoot); await beforeStart?.(); diff --git a/src/lib/autostart-compose-mounts.ts b/src/lib/autostart-compose-mounts.ts new file mode 100644 index 000000000..fec142a4f --- /dev/null +++ b/src/lib/autostart-compose-mounts.ts @@ -0,0 +1,127 @@ +import { isRecord } from "./guards.ts"; + +const VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; +const CONTAINER_PATH = /^\/[^$:]*$/; +const VOLUME_MODES = new Set(["ro", "rw", "nocopy", "volume-nocopy"]); + +/** + * Unattended starts trust prepared images and engine-managed storage, never host + * file content supplied through Compose mounts. Named storage must use the + * project's default local volume, without external aliases or driver options. + */ +export function hasOnlyEngineManagedMounts( + compose: Record +): boolean { + if ( + !isRecord(compose.services) || + nonempty(compose.configs) || + nonempty(compose.secrets) + ) { + return false; + } + const names = declaredLocalVolumes(compose.volumes); + if (names === null) { + return false; + } + return Object.values(compose.services).every((service) => + supportedServiceMounts(service, names) + ); +} + +function nonempty(value: unknown): boolean { + return ( + value !== undefined && + value !== null && + !(isRecord(value) && Object.keys(value).length === 0) + ); +} + +function declaredLocalVolumes(value: unknown): ReadonlySet | null { + if (value === undefined || value === null) { + return new Set(); + } + if (!isRecord(value)) { + return null; + } + const names = new Set(); + for (const [name, definition] of Object.entries(value)) { + if (!VOLUME_NAME.test(name)) { + return null; + } + if ( + definition !== null && + (!isRecord(definition) || + Object.keys(definition).some( + (key) => !["driver", "labels"].includes(key) + ) || + (definition.driver !== undefined && definition.driver !== "local")) + ) { + return null; + } + names.add(name); + } + return names; +} + +function supportedServiceMounts( + service: unknown, + names: ReadonlySet +): boolean { + if (!isRecord(service)) { + return false; + } + if ( + [ + "configs", + "secrets", + "volumes_from", + "label_file", + "credential_spec", + ].some((key) => service[key] !== undefined) + ) { + return false; + } + if (service.volumes === undefined) { + return true; + } + return ( + Array.isArray(service.volumes) && + service.volumes.every((mount) => supportedMount(mount, names)) + ); +} + +function supportedMount(mount: unknown, names: ReadonlySet): boolean { + if (typeof mount === "string") { + const parts = mount.split(":"); + if (parts.length === 1) { + return CONTAINER_PATH.test(mount); + } + const [source, target, mode] = parts; + return ( + parts.length <= 3 && + typeof source === "string" && + names.has(source) && + typeof target === "string" && + CONTAINER_PATH.test(target) && + (mode === undefined || + mode.split(",").every((entry) => VOLUME_MODES.has(entry))) + ); + } + if ( + !isRecord(mount) || + typeof mount.target !== "string" || + !CONTAINER_PATH.test(mount.target) || + mount.bind !== undefined || + mount.image !== undefined + ) { + return false; + } + if (mount.type === "tmpfs") { + return mount.source === undefined; + } + return ( + mount.type === "volume" && + (mount.source === undefined || + (typeof mount.source === "string" && names.has(mount.source))) + ); +} diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index e29da6660..1531ed583 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -12,6 +12,7 @@ import { PROJECT_ENV_STATE_FILENAME, } from "../constants.ts"; import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; +import { hasOnlyEngineManagedMounts } from "./autostart-compose-mounts.ts"; import { HackCliError } from "./cli-result.ts"; import { resolveGlobalConfigPath } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; @@ -188,7 +189,8 @@ function composePolicyReason( !(isRecord(compose) && isRecord(compose.services)) || Object.keys(compose.services).length === 0 || compose.include !== undefined || - compose["<<"] !== undefined + compose["<<"] !== undefined || + !hasOnlyEngineManagedMounts(compose) ) { return "compose_inputs_require_manual_start"; } diff --git a/src/lib/autostart-trust-mounts.ts b/src/lib/autostart-trust-mounts.ts new file mode 100644 index 000000000..d7b901a3d --- /dev/null +++ b/src/lib/autostart-trust-mounts.ts @@ -0,0 +1,144 @@ +import { Buffer } from "node:buffer"; +import { X509Certificate } from "node:crypto"; +import { constants } from "node:fs"; +import { open } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { HackCliError } from "./cli-result.ts"; +import { resolveGlobalHackDir } from "./config-paths.ts"; +import { isRecord } from "./guards.ts"; +import { walkReplaceableChain } from "./path-trust.ts"; + +export interface AutostartTrustedBind { + readonly source: string; + readonly target: string; + readonly readOnly: true; +} +const CERTIFICATES = + /-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+-----END CERTIFICATE-----/g; +const MAX_CERTIFICATE_BYTES = 1024 * 1024; + +/** + * The only generated host mounts admitted at login are the two public TLS trust + * inputs at fixed container destinations. No arbitrary file or executable is + * admitted through this exception, and no trust material is changed here. + */ +export async function resolveAutostartTrustedTlsBinds( + opts: { readonly hackHome?: string } = {} +): Promise { + try { + const root = opts.hackHome ?? resolveGlobalHackDir(); + const pki = resolve(root, "caddy", "pki"); + const ca = resolve(pki, "caddy-local-authority.crt"); + if (!(await authenticateCertificates(ca))) { + return []; + } + const mounts: AutostartTrustedBind[] = [ + { + source: ca, + target: "/etc/hack/ca/caddy-local-authority.crt", + readOnly: true, + }, + ]; + const bundle = resolve(pki, "caddy-host-trust-bundle.pem"); + if (await authenticateCertificates(bundle)) { + mounts.push({ + source: bundle, + target: "/etc/hack/ca/trust-bundle.pem", + readOnly: true, + }); + } + return mounts; + } catch { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Automatic startup refused unsafe generated TLS mount inputs. Review the installed trust files or start manually. Values omitted.", + detail: { reason: "compose_inputs_require_manual_start" }, + }); + } +} + +async function authenticateCertificates(path: string): Promise { + const uid = process.getuid?.(); + if (uid === undefined) { + throw new Error("Unavailable owner"); + } + const parent = walkReplaceableChain({ + path: dirname(path), + uid, + hackHome: null, + allowMissing: true, + }); + if ("error" in parent) { + throw new Error("Unsafe certificate parent"); + } + if (!parent.complete) { + return false; + } + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ).catch((error: unknown) => { + if (isRecord(error) && error.code === "ENOENT") { + return null; + } + throw error; + }); + if (file === null) { + return false; + } + try { + const before = await file.stat(); + if ( + !before.isFile() || + (before.uid !== uid && before.uid !== 0) || + (before.mode & 0o022) !== 0 || + (before.mode & 0o004) === 0 || + before.size > MAX_CERTIFICATE_BYTES + ) { + throw new Error("Unsafe certificate file"); + } + const bytes = new Uint8Array(before.size + 1); + let length = 0; + while (length < bytes.length) { + const result = await file.read( + bytes, + length, + bytes.length - length, + length + ); + if (result.bytesRead === 0) { + break; + } + length += result.bytesRead; + } + const after = await file.stat(); + if ( + length !== before.size || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs || + after.ctimeMs !== before.ctimeMs || + after.mode !== before.mode || + after.uid !== before.uid + ) { + throw new Error("Certificate changed"); + } + const text = Buffer.from(bytes.subarray(0, length)).toString("utf8"); + const certificates = [...text.matchAll(CERTIFICATES)]; + if ( + certificates.length === 0 || + certificates.length > 512 || + text.replace(CERTIFICATES, "").trim() !== "" + ) { + throw new Error("Unsupported certificate content"); + } + for (const [pem] of certificates) { + if (!new X509Certificate(pem).ca) { + throw new Error("Not a CA certificate"); + } + } + return true; + } finally { + await file.close(); + } +} diff --git a/tests/autostart-checkout-trust.test.ts b/tests/autostart-checkout-trust.test.ts index 9d6b629f9..854c3a378 100644 --- a/tests/autostart-checkout-trust.test.ts +++ b/tests/autostart-checkout-trust.test.ts @@ -253,6 +253,7 @@ test.each([ "directory", "config", "hook", + "bind", ])("source login CLI refuses a writable %s before hooks or engine commands", async (kind) => { const hook = join(root, "hook-ran"); const engine = join(root, "engine-ran"); @@ -266,7 +267,9 @@ test.each([ join(projectDir, "hack.config.json"), JSON.stringify({ name: "example", - lifecycle: { up: { before: [`sh ${quoteShellWord(script)}`] } }, + ...(kind === "bind" + ? {} + : { lifecycle: { up: { before: [`sh ${quoteShellWord(script)}`] } } }), }) ); await writeFile( @@ -274,13 +277,19 @@ test.each([ `#!${process.execPath}\nawait Bun.write(${JSON.stringify(engine)}, "ran");\nprocess.exit(99);\n`, { mode: 0o700 } ); + if (kind === "bind") { + await writeFile( + join(projectDir, "docker-compose.yml"), + "services:\n app:\n image: example.invalid/app\n volumes:\n - ../start-hook.sh:/start.sh:ro\n entrypoint: [sh, /start.sh]\n" + ); + } const unsafe = - kind === "hook" + kind === "hook" || kind === "bind" ? script : kind === "directory" ? join(root, "projects") : join(projectDir, "hack.config.json"); - const mode = kind === "hook" ? 0o666 : 0o770; + const mode = kind === "hook" || kind === "bind" ? 0o666 : 0o770; await chmod(unsafe, mode); const child = Bun.spawn( [ @@ -316,9 +325,11 @@ test.each([ ]); expect(code, stdout + stderr).not.toBe(0); expect(stdout + stderr).toContain( - kind === "hook" - ? "host_commands_require_manual_start" - : "checkout_untrusted" + kind === "bind" + ? "compose_inputs_require_manual_start" + : kind === "hook" + ? "host_commands_require_manual_start" + : "checkout_untrusted" ); expect(await Bun.file(hook).exists()).toBe(false); expect(await Bun.file(engine).exists()).toBe(false); diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts index c16e501a0..ba6668f21 100644 --- a/tests/autostart-start-policy.test.ts +++ b/tests/autostart-start-policy.test.ts @@ -218,6 +218,92 @@ test("global and project command/cloud provider declarations refuse without invo ); }); +test("unattended mounts allow default engine storage but refuse host files and indirect sources", async () => { + await writeFile(join(opts.repoRoot, "start.sh"), "synthetic-code", { + mode: 0o600, + }); + await chmod(join(opts.repoRoot, "start.sh"), 0o666); + for (const volumes of [ + ["../start.sh:/start.sh:ro"], + [`${opts.repoRoot}/start.sh:/start.sh:ro`], + [ + { + type: "bind", + source: "../start.sh", + target: "/start.sh", + read_only: true, + }, + ], + ["${MOUNT_SOURCE}:/start.sh"], + [{ type: "image", source: "unverified/image", target: "/start.sh" }], + ]) { + await compose({ + services: { + web: { + image: "example/web:1", + volumes, + entrypoint: ["sh", "/start.sh"], + }, + }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); + } + for (const settings of [ + { + volumes: { + data: { + driver_opts: { type: "none", o: "bind", device: opts.repoRoot }, + }, + }, + }, + { volumes: { data: { external: true } } }, + { volumes: { data: { name: "unverified-existing-volume" } } }, + { configs: { script: { file: "../start.sh" } } }, + { secrets: { script: { file: "../start.sh" } } }, + ]) { + await compose({ + ...settings, + services: { web: { image: "example/web:1" } }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); + } + for (const settings of [ + { volumes_from: ["other"] }, + { label_file: "../labels" }, + { configs: ["script"] }, + { secrets: ["script"] }, + ]) { + await compose({ + services: { web: { image: "example/web:1", ...settings } }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); + } + await compose({ + volumes: { data: {}, cache: { driver: "local" } }, + services: { + web: { + image: "example/web:1", + volumes: [ + "data:/data:ro", + "/scratch", + { type: "volume", source: "cache", target: "/cache" }, + { type: "tmpfs", target: "/tmp" }, + ], + }, + }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); + expect((await stat(join(opts.repoRoot, "start.sh"))).mode & 0o777).toBe( + 0o666 + ); +}); + test("managed graph and process-control keys refuse by name even when ciphertext is opaque", async () => { for (const key of [ "COMPOSE_PROFILES", diff --git a/tests/autostart-trust-mounts.test.ts b/tests/autostart-trust-mounts.test.ts new file mode 100644 index 000000000..0f077e750 --- /dev/null +++ b/tests/autostart-trust-mounts.test.ts @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rootCertificates } from "node:tls"; +import { YAML } from "bun"; +import { composeRuntimeBackend } from "../src/backends/runtime-backend.ts"; +import { renderInternalOverride } from "../src/commands/project.ts"; +import { resolveAutostartTrustedTlsBinds } from "../src/lib/autostart-trust-mounts.ts"; +import { isRecord } from "../src/lib/guards.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +let root = ""; +let pki = ""; +let ca = ""; +let certificate = ""; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-trust-mounts-")); + pki = join(root, "caddy", "pki"); + ca = join(pki, "caddy-local-authority.crt"); + const publicRoot = rootCertificates[0]; + if (!publicRoot) { + throw new Error("Runtime public CA fixture unavailable"); + } + certificate = publicRoot; +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); +async function install() { + await mkdir(pki, { recursive: true, mode: 0o700 }); + await writeFile(ca, certificate, { mode: 0o644 }); + await chmod(ca, 0o644); +} + +test("only existing public CA inputs receive the exact generated read-only mount destinations", async () => { + expect(await resolveAutostartTrustedTlsBinds({ hackHome: root })).toEqual([]); + await install(); + const before = await lstat(ca); + expect(await resolveAutostartTrustedTlsBinds({ hackHome: root })).toEqual([ + { + source: ca, + target: "/etc/hack/ca/caddy-local-authority.crt", + readOnly: true, + }, + ]); + const bundle = join(pki, "caddy-host-trust-bundle.pem"); + await writeFile(bundle, `${certificate}\n${certificate}\n`, { mode: 0o644 }); + expect(await resolveAutostartTrustedTlsBinds({ hackHome: root })).toEqual([ + { + source: ca, + target: "/etc/hack/ca/caddy-local-authority.crt", + readOnly: true, + }, + { source: bundle, target: "/etc/hack/ca/trust-bundle.pem", readOnly: true }, + ]); + expect((await lstat(ca)).ino).toBe(before.ino); + expect((await lstat(ca)).mode).toBe(before.mode); + expect(await readFile(ca, "utf8")).toBe(certificate); +}); + +test("writable or container-unreadable certificate inputs refuse without permission repair", async () => { + await install(); + for (const mode of [0o666, 0o600]) { + await chmod(ca, mode); + await expect( + resolveAutostartTrustedTlsBinds({ hackHome: root }) + ).rejects.toMatchObject({ + detail: { reason: "compose_inputs_require_manual_start" }, + }); + expect((await lstat(ca)).mode & 0o777).toBe(mode); + } +}); + +test("symlinked inputs and unsafe parent directories are never admitted as generated trust mounts", async () => { + await install(); + const target = join(root, "retained-ca"); + await writeFile(target, certificate, { mode: 0o644 }); + await rm(ca); + await symlink(target, ca); + await expect( + resolveAutostartTrustedTlsBinds({ hackHome: root }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(await readFile(target, "utf8")).toBe(certificate); + await rm(ca); + await writeFile(ca, certificate, { mode: 0o644 }); + await chmod(pki, 0o777); + await expect( + resolveAutostartTrustedTlsBinds({ hackHome: root }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect((await lstat(pki)).mode & 0o777).toBe(0o777); +}); + +test("script bytes, appended non-certificate content and oversized inputs cannot use the TLS exception", async () => { + await install(); + for (const text of [ + "#!/bin/sh\nexit 0\n", + `${certificate}\nprintf unsafe\n`, + "x".repeat(1024 * 1024 + 1), + ]) { + await writeFile(ca, text); + await expect( + resolveAutostartTrustedTlsBinds({ hackHome: root }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect((await lstat(ca)).size).toBe(Buffer.byteLength(text)); + } +}); + +test("the generated default TLS mount passes effective admission with authenticated certificates", async () => { + await install(); + const generated: unknown = YAML.parse( + renderInternalOverride({ + services: [{ name: "app", enableInternalDns: true }], + dnsServer: null, + extraHosts: {}, + caPath: ca, + trustBundlePath: null, + }) + ); + const services = + isRecord(generated) && isRecord(generated.services) + ? generated.services + : undefined; + const app = services?.app; + const volumes: unknown = isRecord(app) ? app.volumes : undefined; + if ( + !( + Array.isArray(volumes) && + volumes.every((mount): mount is string => typeof mount === "string") + ) + ) { + throw new Error("Generated TLS mounts did not match the expected schema"); + } + const mounts = volumes.map((mount) => { + const [source, target, mode] = mount.split(":"); + return { + type: "bind", + source, + target, + read_only: mode === "ro", + bind: { create_host_path: true }, + }; + }); + const model = { + name: "example", + services: { app: { image: "example/app:1", volumes: mounts } }, + }; + const bin = join(root, "bin"); + const phases = join(root, "phases"); + await mkdir(bin); + await writeFile( + join(bin, "docker"), + `#!${process.execPath} +const args=process.argv.slice(2); +if(args.includes("config")) { console.log(${JSON.stringify(JSON.stringify(model))});process.exit(0); } +if(args[0]==="image") { console.log("sha256:"+"a".repeat(64));process.exit(0); } +if(args.includes("up")) { const file=Bun.file(${JSON.stringify(phases)});await Bun.write(file,(await file.exists()?await file.text():"")+"phase\\n");process.exit(0); } +process.exit(97); +`, + { mode: 0o700 } + ); + let authentications = 0; + expect( + await composeRuntimeBackend.up({ + composeFiles: [join(root, "compose.yml")], + cwd: root, + env: privateCliEnv({ root, env: { PATH: bin } }), + detach: true, + unattended: true, + unattendedTrustedBinds: () => { + authentications += 1; + return resolveAutostartTrustedTlsBinds({ hackHome: root }); + }, + imageTimeoutMs: 5000, + startupTimeoutMs: 5000, + }) + ).toBe(0); + expect(authentications).toBeGreaterThanOrEqual(3); + expect((await Bun.file(phases).text()).trim().split("\n")).toHaveLength(2); +}); diff --git a/tests/child-environment.test.ts b/tests/child-environment.test.ts index 3aebc1dbe..2c3f59a69 100644 --- a/tests/child-environment.test.ts +++ b/tests/child-environment.test.ts @@ -10,6 +10,7 @@ import { import { runLifecycleCommandChild } from "../src/lib/lifecycle-command-child.ts"; import { exec, run } from "../src/lib/shell.ts"; import { restoreEnv } from "./helpers/env.ts"; +import { fixtureGitEnv } from "./helpers/fixture-git.ts"; import { privateCliEnv, privateCliRunEnv } from "./helpers/private-cli-env.ts"; const SENTINEL = "synthetic-child-key-sentinel"; @@ -295,9 +296,12 @@ test("login Git observations disable monitor hooks despite local config and inhe mode: 0o700, }); const env = privateCliEnv({ root }); - const git = async (args: readonly string[], childEnv = env) => { + const git = async ( + args: readonly string[], + childEnv?: Record + ) => { const child = Bun.spawn(["git", "-C", root, ...args], { - env: childEnv, + env: childEnv ?? fixtureGitEnv({ env }), stdin: "ignore", stdout: "ignore", stderr: "ignore", @@ -307,7 +311,7 @@ test("login Git observations disable monitor hooks despite local config and inhe expect(await git(["init"])).toBe(0); expect(await git(["config", "core.fsmonitor", monitor])).toBe(0); const unattended = childEnvironment( - { ...env, HACK_AUTOSTART_LOGIN: "1" }, + { ...fixtureGitEnv({ env }), HACK_AUTOSTART_LOGIN: "1" }, { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.fsmonitor", diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md index e2bd69ea2..486f66565 100644 --- a/tests/models/tla/autostart-start-stop/COVERAGE.md +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -57,6 +57,14 @@ Closed manual-start refusals can release a boot claim through a boot/branch compare-and-set before any start. This model does not cover that registry ledger; `project-autostart.test.ts` covers stale, changed-target and real-failure refusal cases, and daemon tests cover neutral progress and same-boot recovery. +The unattended effective-model guard rejects host-file execution inputs and +checks named-volume driver/options/Compose labels immediately before preparation +and start. Only fixed, authenticated read-only generated TLS mounts are admitted. +Runtime-backend tests include hidden generated binds, old bind-backed engine +volumes, ownership labels, safe missing/default volumes and withdrawn TLS trust. +These are implementation regressions; the finite model does not model Docker +storage metadata, certificate parsing, privileged engine actors or application +code/data inside prepared images and engine-managed volumes. Multiple projects, multiple login attempts, reboot, upgrades, global pause, retries, network prompts, native backends and real login-service behavior are outside this model. Component model success does not qualify installed binaries or real login. diff --git a/tests/runtime-backend-unattended.test.ts b/tests/runtime-backend-unattended.test.ts index 51e2813ac..f220424bb 100644 --- a/tests/runtime-backend-unattended.test.ts +++ b/tests/runtime-backend-unattended.test.ts @@ -28,7 +28,7 @@ test("real unattended Compose subprocess cannot build or pull to recover a missi join(bin, "docker"), `#!${process.execPath} const args=process.argv.slice(2); -if(args.includes("config") && args.includes("--images")) { process.stdout.write("example.invalid/locally-prepared:latest\\n");process.exit(0); } +if(args.includes("config") && args.includes("--format")) { process.stdout.write(JSON.stringify({name:"fixture",services:{app:{image:"example.invalid/locally-prepared:latest"}}}));process.exit(0); } if(args[0]==="image" && args[1]==="inspect") { process.stderr.write("Error response from daemon: No such image: example.invalid/locally-prepared:latest\\n");process.exit(1); } await Bun.write(${JSON.stringify(called)},JSON.stringify(args)); const pull=args.indexOf("--pull"); const dotenv=args.indexOf("--env-file"); @@ -56,3 +56,91 @@ process.exit(1); expect(await Bun.file(unsafe).exists()).toBe(false); expect(await Bun.file(called).exists()).toBe(false); }); + +for (const scenario of [ + "unsafe-existing", + "safe-existing", + "safe-missing", + "generated-bind", +] as const) { + test(`real unattended resolved-storage probes handle ${scenario} before any unsafe up`, async () => { + const root = await mkdtemp(join(tmpdir(), "hack-storage-probe-")); + roots.push(root); + const bin = join(root, "bin"); + await mkdir(bin); + const called = join(root, "up-calls"); + const compose = join(root, "compose.yml"); + await writeFile( + compose, + "name: fixture\nservices:\n app:\n image: example.invalid/app\n volumes:\n - data:/data\nvolumes:\n data: {}\n" + ); + const model = { + name: "fixture", + services: { + app: { + image: "example.invalid/app", + environment: { PRIVATE_SENTINEL: "synthetic-model-value" }, + volumes: [ + scenario === "generated-bind" + ? { + type: "bind", + source: "/host/private", + target: "/data", + read_only: true, + } + : { type: "volume", source: "data", target: "/data" }, + ], + }, + }, + volumes: { data: { name: "fixture_data" } }, + }; + const inspected = { + Name: "fixture_data", + Driver: "local", + Options: + scenario === "unsafe-existing" + ? { device: "/host/private", o: "bind", type: "none" } + : null, + Labels: { + "com.docker.compose.project": "fixture", + "com.docker.compose.volume": "data", + }, + }; + await writeFile( + join(bin, "docker"), + `#!${process.execPath} +const args=process.argv.slice(2); +if(args.includes("config")){process.stdout.write(${JSON.stringify(JSON.stringify(model))});process.exit(0);} +if(args[0]==="image"){process.stdout.write("sha256:${"a".repeat(64)}");process.exit(0);} +if(args[0]==="volume"){ + if(${JSON.stringify(scenario)}==="safe-missing" && !(await Bun.file(${JSON.stringify(called)}).exists())){process.stderr.write("Error response from daemon: get fixture_data: no such volume\\n");process.exit(1);} + process.stdout.write(${JSON.stringify(JSON.stringify(inspected))});process.exit(0); +} +if(args.includes("up")){const f=Bun.file(${JSON.stringify(called)});await Bun.write(f,(await f.exists()?await f.text():"")+"up\\n");process.exit(0);} +process.exit(99); +`, + { mode: 0o755 } + ); + const action = composeRuntimeBackend.up({ + composeFiles: [compose, "generated.yml"], + cwd: root, + env: privateCliEnv({ root, env: { PATH: bin } }), + detach: true, + unattended: true, + imageTimeoutMs: 10_000, + startupTimeoutMs: 10_000, + }); + if (scenario === "unsafe-existing" || scenario === "generated-bind") { + await expect(action).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "compose_inputs_require_manual_start" }, + }); + expect(await Bun.file(called).exists()).toBe(false); + } else { + expect(await action).toBe(0); + expect((await Bun.file(called).text()).trim().split("\n")).toHaveLength( + 2 + ); + } + }); +} diff --git a/tests/runtime-backend.test.ts b/tests/runtime-backend.test.ts index b94c77721..50e413c21 100644 --- a/tests/runtime-backend.test.ts +++ b/tests/runtime-backend.test.ts @@ -22,7 +22,15 @@ const shellMock = await registerScopedModuleMock({ overrides: { exec: async (cmd: readonly string[]) => { execCalls.push([...cmd]); - return execResults.shift() ?? { exitCode: 0, stdout: "", stderr: "" }; + return ( + execResults.shift() ?? { + exitCode: 0, + stdout: cmd.includes("config") + ? JSON.stringify({ name: "fixture", services: {} }) + : "", + stderr: "", + } + ); }, execOrThrow: async (cmd: readonly string[]) => { execCalls.push([...cmd]); @@ -561,7 +569,14 @@ test("withdrawn unattended intent refuses before create, pull or build phases", test("only an exact missing image diagnostic authorizes an unattended manual-start refusal", async () => { const backend = await loadComposeRuntimeBackend(); execResults.push( - { exitCode: 0, stdout: "example.invalid/app\n", stderr: "" }, + { + exitCode: 0, + stdout: JSON.stringify({ + name: "fixture", + services: { app: { image: "example.invalid/app" } }, + }), + stderr: "", + }, { exitCode: 1, stdout: "", @@ -594,7 +609,14 @@ test("only an exact missing image diagnostic authorizes an unattended manual-sta test("images present plus a failed prepare phase remains an ordinary claimed failure", async () => { const backend = await loadComposeRuntimeBackend(); execResults.push( - { exitCode: 0, stdout: "example.invalid/app\n", stderr: "" }, + { + exitCode: 0, + stdout: JSON.stringify({ + name: "fixture", + services: { app: { image: "example.invalid/app" } }, + }), + stderr: "", + }, { exitCode: 0, stdout: `sha256:${"a".repeat(64)}\n`, stderr: "" } ); runResults.push(17); @@ -618,7 +640,14 @@ for (const diagnostic of [ test(`image inspection refuses unknown errors without a neutral missing-image result: ${diagnostic.split("\n")[0]}`, async () => { const backend = await loadComposeRuntimeBackend(); execResults.push( - { exitCode: 0, stdout: "example.invalid/app\n", stderr: "" }, + { + exitCode: 0, + stdout: JSON.stringify({ + name: "fixture", + services: { app: { image: "example.invalid/app" } }, + }), + stderr: "", + }, { exitCode: 1, stdout: "", stderr: diagnostic } ); expect( @@ -665,3 +694,292 @@ test("attached unattended startup refuses before invoking Compose", async () => }); expect(runCalls).toEqual([]); }); + +function ordinaryVolumeModel( + mount: unknown = { type: "volume", source: "data", target: "/data" } +) { + return { + name: "fixture", + services: { app: { image: "example.invalid/app", volumes: [mount] } }, + volumes: { data: { name: "fixture_data" } }, + }; +} +function existingVolume( + options: unknown = null, + labels: unknown = { + "com.docker.compose.project": "fixture", + "com.docker.compose.volume": "data", + } +) { + return { + Name: "fixture_data", + Driver: "local", + Options: options, + Labels: labels, + }; +} +function queueModelAndImage(model: unknown) { + execResults.push( + { exitCode: 0, stdout: JSON.stringify(model), stderr: "" }, + { exitCode: 0, stdout: `sha256:${"a".repeat(64)}\n`, stderr: "" } + ); +} +function queueVolume(value: unknown) { + execResults.push({ exitCode: 0, stdout: JSON.stringify(value), stderr: "" }); +} + +for (const badVolume of [ + existingVolume({ device: "/host/private", o: "bind", type: "none" }), + existingVolume(null, null), + existingVolume(null, { + "com.docker.compose.project": "other", + "com.docker.compose.volume": "data", + }), + { ...existingVolume(), Driver: "custom" }, +]) { + test("unattended runtime refuses unsafe existing named-volume options or ownership labels before up", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage(ordinaryVolumeModel()); + queueVolume(badVolume); + await expect( + backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "compose_inputs_require_manual_start" }, + }); + expect(runCalls).toEqual([]); + }); +} +for (const options of [null, {}]) { + test("an ordinary Compose-owned local named volume is checked before create and start", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage(ordinaryVolumeModel()); + queueVolume(existingVolume(options)); + queueVolume(existingVolume(options)); + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).toBe(0); + expect(execCalls.filter((args) => args[1] === "volume")).toHaveLength(2); + expect(runCalls).toHaveLength(2); + }); +} + +test("a missing default local named volume is eligible to create only on an exact expected-name diagnostic", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage(ordinaryVolumeModel()); + execResults.push({ + exitCode: 1, + stdout: "", + stderr: "Error response from daemon: get fixture_data: no such volume\n", + }); + queueVolume(existingVolume()); + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).toBe(0); + expect(runCalls).toHaveLength(2); +}); +for (const message of [ + "Cannot connect to the Docker daemon", + "Error response from daemon: unauthorized", + "Error response from daemon: get other: no such volume", +]) { + test("unknown volume probe errors remain claimed failures without preparation", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage(ordinaryVolumeModel()); + execResults.push({ exitCode: 1, stdout: "", stderr: message }); + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).toBe(1); + expect(runCalls).toEqual([]); + }); +} + +test("a generated resolved bind is refused even when the primary authored model had only an ordinary volume", async () => { + const backend = await loadComposeRuntimeBackend(); + execResults.push({ + exitCode: 0, + stdout: JSON.stringify( + ordinaryVolumeModel({ + type: "bind", + source: "/host/private", + target: "/data", + read_only: true, + }) + ), + stderr: "", + }); + await expect( + backend.up({ + composeFiles: ["compose.yml", "generated.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "compose_inputs_require_manual_start" }, + }); + expect(runCalls).toEqual([]); + expect(execCalls).toHaveLength(1); +}); + +test("anonymous volumes and tmpfs do not require a named-volume inspection", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage({ + name: "fixture", + services: { + app: { + image: "example.invalid/app", + volumes: [ + { type: "volume", target: "/data" }, + { type: "tmpfs", target: "/tmp" }, + ], + }, + }, + }); + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + }) + ).toBe(0); + expect(execCalls).toHaveLength(2); +}); + +const trustedTls = { + source: "/trusted/caddy/root.crt", + target: "/etc/hack/ca/caddy-local-authority.crt", + readOnly: true, +} as const; +function tlsModel(readOnly = true) { + return { + name: "fixture", + services: { + app: { + image: "example.invalid/app", + volumes: [ + { + type: "bind", + source: trustedTls.source, + target: trustedTls.target, + read_only: readOnly, + bind: { create_host_path: true }, + }, + ], + }, + }, + }; +} +test("only exact read-only authenticated machine TLS binds survive resolved-model checks", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage(tlsModel()); + let authenticated = 0; + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + unattendedTrustedBinds: async () => { + authenticated += 1; + return [trustedTls]; + }, + }) + ).toBe(0); + expect(authenticated).toBe(3); + expect(runCalls).toHaveLength(2); +}); +test("an approved TLS source must still authenticate at the create boundary", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage(tlsModel()); + let authenticated = 0; + await expect( + backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + unattendedTrustedBinds: async () => + ++authenticated === 1 ? [trustedTls] : [], + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "compose_inputs_require_manual_start" }, + }); + expect(runCalls).toEqual([]); +}); +test("a writable bind cannot borrow the authenticated TLS allowlist", async () => { + const backend = await loadComposeRuntimeBackend(); + execResults.push({ + exitCode: 0, + stdout: JSON.stringify(tlsModel(false)), + stderr: "", + }); + await expect( + backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + unattendedTrustedBinds: async () => [trustedTls], + }) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "compose_inputs_require_manual_start" }, + }); + expect(runCalls).toEqual([]); +}); + +for (const withVolume of [false, true]) { + test("unused installed TLS state cannot reject an unattended model without binds", async () => { + const backend = await loadComposeRuntimeBackend(); + queueModelAndImage( + withVolume + ? ordinaryVolumeModel() + : { + name: "fixture", + services: { app: { image: "example.invalid/app" } }, + } + ); + if (withVolume) { + queueVolume(existingVolume()); + queueVolume(existingVolume()); + } + let authCalls = 0; + expect( + await backend.up({ + composeFiles: ["compose.yml"], + cwd: "/fixture", + detach: true, + unattended: true, + unattendedTrustedBinds: async () => { + authCalls += 1; + throw new Error("unused TLS installation is unsafe"); + }, + }) + ).toBe(0); + expect(authCalls).toBe(0); + }); +} From bdcbdc24c98f2258e7d3c448570dcfe1c56533a4 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:31:02 +0000 Subject: [PATCH 19/44] fix(autostart): preserve adoption recovery and authenticate trust ACLs --- docs/always-on-projects.md | 9 +- docs/versions/v5/getting-started.md | 1 + src/backends/compose-unattended-preflight.ts | 12 +- src/commands/project-autostart-intent.ts | 8 + src/daemon/systemd.ts | 20 ++ src/lib/autostart-checkout-trust.ts | 2 + src/lib/autostart-docker-trust.ts | 2 + src/lib/autostart-executable-trust.ts | 19 +- src/lib/autostart-path-acl.ts | 130 ++++++++++++ src/lib/autostart-project-lock.ts | 13 ++ src/lib/autostart-refusal.ts | 13 ++ src/lib/autostart-routing-assets.ts | 13 +- src/lib/autostart-start-policy.ts | 51 +++-- src/lib/autostart-trust-mounts.ts | 5 + src/lib/native-compose-adoption-command.ts | 2 + src/lib/projects-registry.ts | 5 + tests/autostart-executable-trust.test.ts | 75 +++++++ tests/autostart-path-acl.test.ts | 195 ++++++++++++++++++ tests/daemon-autostart.test.ts | 51 ++--- ...tive-compose-adoption-dependency-inputs.ts | 45 ++++ .../native-compose-adoption-worktrees.ts | 29 ++- ...ompose-adoption-dependency-fixture.test.ts | 29 +++ ...mpose-adoption-generation-retained.test.ts | 96 ++++++++- tests/runtime-backend-unattended.test.ts | 21 +- 24 files changed, 783 insertions(+), 63 deletions(-) create mode 100644 src/lib/autostart-path-acl.ts create mode 100644 tests/autostart-path-acl.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 269fbb67d..d460c6fa1 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -73,7 +73,11 @@ Only automatic passes check the derived executable path before helper lookup too. Directories, executable files and symlink targets must satisfy the same read-only ownership and permission checks used by the Linux service; unsafe paths are refused without automatic permission repair. This strict policy also -applies to group-writable Homebrew directories, including group `admin`: install +checks Darwin ACLs on lookup directories, programs and symlink chains, checkout +and env inputs, Docker config and plugins, registries and locks, routing assets +and TLS trust files; mutation +grants, unknown rights or an unsuccessful bounded inspection refuse startup. +It also applies to group-writable Homebrew directories, including group `admin`: install required helpers in a trusted lookup directory before retrying. A refused search path does not consume a project's attempt for this boot. The daemon keeps its installed PATH and continues normal status observation; @@ -104,6 +108,9 @@ declarations require manual startup in this slice. Images must already be presen locally: both Compose phases use `--no-build --pull never` and disable implicit dotenv imports. Missing images never trigger a build or pull. The intent/trust guard runs before preparation as well as before container start. +Retained projects created by `hack config adopt` also require manual starts; +their runtime does not implement these unattended guards. Manual adoption and +`hack down --recover` retain their existing recovery path. These manual-start requirements produce a closed skip and consume no boot claim or failure entry. Doctor reports the reason. Fix the unsupported prerequisite or start manually; ordinary manual starts retain their behavior. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 65db7ac02..10246964e 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -72,6 +72,7 @@ permission refusals, including app-bundle and Homebrew paths on macOS. This slice starts only eligible Compose projects with images already available locally. Hooks, host helpers, command providers and image builds require manual `hack up`; doctor explains those manual-start requirements. +Retained projects created by `hack config adopt` also require manual starts. Host-file/source-code bind mounts also require manual starts. Verified default engine volumes and Hack's authenticated read-only TLS mounts remain supported. diff --git a/src/backends/compose-unattended-preflight.ts b/src/backends/compose-unattended-preflight.ts index 6b9d63f26..3fe7d57f6 100644 --- a/src/backends/compose-unattended-preflight.ts +++ b/src/backends/compose-unattended-preflight.ts @@ -201,7 +201,7 @@ function probeFor(opts: RuntimeUpOptions, timeoutMs: number) { if (remaining <= 0) { return { exitCode: 124, stdout: "", stderr: "" }; } - return await exec(args, { + const result = await exec(args, { cwd: opts.cwd, env: opts.env, stdin: "ignore", @@ -210,6 +210,16 @@ function probeFor(opts: RuntimeUpOptions, timeoutMs: number) { maxStderrBytes: 4096, forwardSignals: true, }); + // Bounded captures preserve bytes as Latin-1; Compose emits UTF-8 JSON. + return { + ...result, + stdout: new TextDecoder("utf-8", { fatal: true }).decode( + Buffer.from(result.stdout, "latin1") + ), + stderr: new TextDecoder("utf-8", { fatal: true }).decode( + Buffer.from(result.stderr, "latin1") + ), + }; }; } type Probe = ReturnType; diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 5f5dbe11a..220faaa4f 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -13,11 +13,13 @@ import { assertAutostartProjectCurrent, withAutostartProjectLock, } from "../lib/autostart-project-lock.ts"; +import { assertAutostartAdoptionAllowed } from "../lib/autostart-refusal.ts"; import { assertAutostartStartPolicy } from "../lib/autostart-start-policy.ts"; import { resolveEffectiveBranch } from "../lib/branches.ts"; import { HackCliError } from "../lib/cli-result.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { pathExists } from "../lib/fs.ts"; +import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; import { findProjectContextAtRoot, type ProjectContext, @@ -129,6 +131,12 @@ async function discoverAutostartCommandContext( } | undefined > { + // Retained adoption owns recovery, including pending operations that ordinary + // input discovery deliberately refuses. Do not fence its dispatcher here. + if (await selectLegacyComposeAdoptedRoot(opts)) { + assertAutostartAdoptionAllowed(); + return undefined; + } const requested = resolve(opts.cwd, opts.path ?? "."); const cwd = await realpath(requested).catch(() => requested); const selected = diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 0ccb20a74..a33d60bd3 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -2012,6 +2012,8 @@ export async function checkServicePathDirectories(opts: { readonly dirs: readonly string[]; /** Defaults to this process's uid. */ readonly uid?: number; + /** Optional inventory for additional read-only platform checks; never a trust cache. */ + readonly inspectedPaths?: Set; }): Promise { const uid = opts.uid ?? process.getuid?.() ?? -1; const checked = new Set(); @@ -2020,16 +2022,19 @@ export async function checkServicePathDirectories(opts: { if (!isAbsolute(dir)) { return `the hackd.service PATH has a directory that is not absolute (${dir || "empty"})`; } + const directoryPaths = opts.inspectedPaths ? new Set() : undefined; const walk = await walkReplaceableChain({ path: dir, uid, // Not repaired: programs may already have been planted. hackHome: null, allowMissing: true, + checked: directoryPaths, }); if ("error" in walk) { return `the hackd.service PATH directory ${dir} is not safe: ${walk.error}`; } + recordInspectedPaths(directoryPaths, opts.inspectedPaths); const writable = lettingOthersCreate(walk.existing); if (writable !== null) { return `other users can ${walk.complete ? "add programs to" : "create"} the hackd.service PATH directory ${dir} (${walk.existing}, ${writable}); run: chmod go-w ${walk.existing}`; @@ -2041,15 +2046,28 @@ export async function checkServicePathDirectories(opts: { dir: walk.existing, uid, checked, + inspectedPaths: opts.inspectedPaths, }); if (program !== null) { return `the hackd.service PATH directory ${dir} holds ${program}`; } } } + recordInspectedPaths(checked, opts.inspectedPaths); return null; } +function recordInspectedPaths( + paths?: ReadonlySet, + target?: Set +): void { + if (paths && target) { + for (const path of paths) { + target.add(path); + } + } +} + /** * The programs already in a PATH directory: hackd runs helpers from it * (`docker events` at every start), so none may be writable by group or @@ -2064,6 +2082,7 @@ async function checkPathDirectoryPrograms(opts: { readonly dir: string; readonly uid: number; readonly checked: Set; + readonly inspectedPaths?: Set; }): Promise { let entries: string[]; let systemDir: boolean; @@ -2093,6 +2112,7 @@ async function checkPathDirectoryPrograms(opts: { if (error !== null) { return error; } + opts.inspectedPaths?.add(path); } return null; } diff --git a/src/lib/autostart-checkout-trust.ts b/src/lib/autostart-checkout-trust.ts index a210cc174..f5716a2db 100644 --- a/src/lib/autostart-checkout-trust.ts +++ b/src/lib/autostart-checkout-trust.ts @@ -1,5 +1,6 @@ import { readdirSync, realpathSync, statSync } from "node:fs"; import { join } from "node:path"; +import { assertAutostartPathAclsTrustedSync } from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; import { checkReplaceableChain, walkReplaceableChain } from "./path-trust.ts"; @@ -116,6 +117,7 @@ export function assertAutostartCheckoutTrusted(opts: { }; checkGenerated(join(opts.projectDir, ".internal"), 0); checkGenerated(join(opts.projectDir, ".branch"), 0); + assertAutostartPathAclsTrustedSync(checked); } catch { throw refusal(); } diff --git a/src/lib/autostart-docker-trust.ts b/src/lib/autostart-docker-trust.ts index 5724e2957..a6497cc18 100644 --- a/src/lib/autostart-docker-trust.ts +++ b/src/lib/autostart-docker-trust.ts @@ -1,5 +1,6 @@ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { join } from "node:path"; +import { assertAutostartPathAclsTrustedSync } from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; import { walkReplaceableChain } from "./path-trust.ts"; @@ -66,6 +67,7 @@ export function assertAutostartDockerConfigTrusted(opts: { visit(directory, 0); } const configPath = join(root, "config.json"); + assertAutostartPathAclsTrustedSync(checked); const configTrust = walkReplaceableChain({ path: configPath, uid, diff --git a/src/lib/autostart-executable-trust.ts b/src/lib/autostart-executable-trust.ts index c0ff9dc23..9906660f3 100644 --- a/src/lib/autostart-executable-trust.ts +++ b/src/lib/autostart-executable-trust.ts @@ -1,4 +1,8 @@ import { checkServicePathDirectories } from "../daemon/systemd.ts"; +import { + type AutostartAclDependencies, + assertAutostartPathAclsTrusted, +} from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; /** @@ -6,14 +10,19 @@ import { HackCliError } from "./cli-result.ts"; * both platforms. Check every lookup directory and its executable/symlink * entries before any helper runs; inherited shell paths are never repaired. */ -export async function assertAutostartExecutablePathTrusted(opts: { - readonly path: string; -}): Promise { +export async function assertAutostartExecutablePathTrusted( + opts: { readonly path: string }, + deps?: AutostartAclDependencies +): Promise { try { + const inspectedPaths = new Set(); if ( - (await checkServicePathDirectories({ dirs: opts.path.split(":") })) === - null + (await checkServicePathDirectories({ + dirs: opts.path.split(":"), + inspectedPaths, + })) === null ) { + await assertAutostartPathAclsTrusted(inspectedPaths, deps); return; } } catch { diff --git a/src/lib/autostart-path-acl.ts b/src/lib/autostart-path-acl.ts new file mode 100644 index 000000000..fc87f9f4f --- /dev/null +++ b/src/lib/autostart-path-acl.ts @@ -0,0 +1,130 @@ +import { spawnSync } from "node:child_process"; +import { dirname } from "node:path"; +import { hasDevMutationAcl } from "./dev-acl.ts"; +import { walkReplaceableChain } from "./path-trust.ts"; +import { exec } from "./shell.ts"; + +interface AclInspection { + readonly exitCode: number; + readonly stdout: string; +} +export interface AutostartAclDependencies { + readonly platform?: NodeJS.Platform; + readonly inspect?: ( + paths: readonly string[], + timeoutMs: number + ) => Promise; + readonly inspectSync?: ( + paths: readonly string[], + timeoutMs: number + ) => AclInspection; +} + +async function inspect(paths: readonly string[], timeoutMs: number) { + return await exec(["/bin/ls", "-blde", "--", ...paths], { + stdin: "ignore", + env: { LC_ALL: "C", CLICOLOR: "", CLICOLOR_FORCE: "" }, + timeoutMs, + maxStdoutBytes: 1024 * 1024, + maxStderrBytes: 4096, + forwardSignals: true, + }); +} + +/** Inspect Darwin ACLs in bounded batches; unknown grants and probe failures refuse. */ +export async function assertAutostartPathAclsTrusted( + paths: ReadonlySet, + deps?: AutostartAclDependencies +): Promise { + for (const { entries, remaining } of inspectionBatches( + paths, + deps?.platform + )) { + const result = await (deps?.inspect ?? inspect)( + entries, + Math.min(3000, remaining) + ); + if (result.exitCode !== 0 || hasDevMutationAcl(result.stdout)) { + throw new Error("Executable ACL trust could not be established"); + } + } +} + +function* inspectionBatches( + paths: ReadonlySet, + platform = process.platform +) { + if (platform !== "darwin") { + return; + } + if (paths.size === 0 || paths.size > 16_384) { + throw new Error("ACL inspection exceeds bound"); + } + const pathsArray = [...paths]; + const deadline = Date.now() + 15_000; + for (let offset = 0; offset < pathsArray.length; offset += 128) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error("ACL inspection exceeded deadline"); + } + yield { entries: pathsArray.slice(offset, offset + 128), remaining }; + } +} + +function inspectSync( + paths: readonly string[], + timeoutMs: number +): AclInspection { + const result = spawnSync("/bin/ls", ["-blde", "--", ...paths], { + env: { LC_ALL: "C", CLICOLOR: "", CLICOLOR_FORCE: "" }, + timeout: timeoutMs, + killSignal: "SIGKILL", + maxBuffer: 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + return { + exitCode: result.error ? 1 : (result.status ?? 1), + stdout: result.stdout?.toString("utf8") ?? "", + }; +} + +/** Synchronous metadata readers use the same bounded ACL policy before consuming inputs. */ +export function assertAutostartPathAclsTrustedSync( + paths: ReadonlySet, + deps?: AutostartAclDependencies +): void { + for (const { entries, remaining } of inspectionBatches( + paths, + deps?.platform + )) { + const result = (deps?.inspectSync ?? inspectSync)( + entries, + Math.min(3000, remaining) + ); + if (result.exitCode !== 0 || hasDevMutationAcl(result.stdout)) { + throw new Error("Path ACL trust could not be established"); + } + } +} + +/** For no-follow descriptor readers, inspect the parent chain and the opened entry. */ +export async function assertAutostartFileAclsTrusted( + opts: { readonly path: string; readonly uid: number }, + deps?: AutostartAclDependencies +): Promise { + if ((deps?.platform ?? process.platform) !== "darwin") { + return; + } + const checked = new Set(); + const parent = walkReplaceableChain({ + path: dirname(opts.path), + uid: opts.uid, + hackHome: null, + checked, + }); + if ("error" in parent) { + throw new Error("Unsafe ACL parent"); + } + checked.add(opts.path); + await assertAutostartPathAclsTrusted(checked, deps); +} diff --git a/src/lib/autostart-project-lock.ts b/src/lib/autostart-project-lock.ts index 614f8e797..5b1f58ab5 100644 --- a/src/lib/autostart-project-lock.ts +++ b/src/lib/autostart-project-lock.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; import { assertAutostartDockerConfigTrusted } from "./autostart-docker-trust.ts"; +import { assertAutostartFileAclsTrusted } from "./autostart-path-acl.ts"; import { assertAutostartStartPolicy } from "./autostart-start-policy.ts"; import { HackCliError } from "./cli-result.ts"; import { @@ -46,6 +47,18 @@ export async function withAutostartProjectLock(opts: { "Project start/stop lock directory is unsafe; no runtime operation ran.", }); } + try { + await assertAutostartFileAclsTrusted({ + path: directory, + uid: process.getuid?.() ?? -1, + }); + } catch { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Project start/stop lock directory is unsafe; no runtime operation ran.", + }); + } const key = createHash("sha256") .update( JSON.stringify({ diff --git a/src/lib/autostart-refusal.ts b/src/lib/autostart-refusal.ts index 8038267c1..fc591dda0 100644 --- a/src/lib/autostart-refusal.ts +++ b/src/lib/autostart-refusal.ts @@ -8,6 +8,7 @@ const MANUAL_START_REASONS = new Set([ "compose_inputs_require_manual_start", "env_file_requires_manual_start", "pending_env_migration", + "adopted_project_requires_manual_start", ]); /** Closed pre-start refusals do not consume a claim or report a startup failure. */ @@ -17,6 +18,18 @@ export function isAutostartManualStartReason( return typeof reason === "string" && MANUAL_START_REASONS.has(reason); } +/** Retained adoption has its own runtime and does not implement unattended guards. */ +export function assertAutostartAdoptionAllowed(): void { + if (process.env.HACK_AUTOSTART_LOGIN === "1") { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "Adopted projects require a manual start. No unattended adoption operation ran.", + detail: { reason: "adopted_project_requires_manual_start" }, + }); + } +} + /** Enforce the boundary again at host-execution sites, using their actual inputs. */ export function assertAutostartHostCommandsAllowed(opts: { readonly hasCommands: boolean; diff --git a/src/lib/autostart-routing-assets.ts b/src/lib/autostart-routing-assets.ts index dbab0efd8..08ec630e1 100644 --- a/src/lib/autostart-routing-assets.ts +++ b/src/lib/autostart-routing-assets.ts @@ -35,6 +35,7 @@ import { renderProjectEnvSchemaJson, renderProjectManagedEnvSchemaJson, } from "../templates.ts"; +import { assertAutostartPathAclsTrusted } from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; import { parseDefaultDomain } from "./default-domain.ts"; import { checkReplaceableChain } from "./path-trust.ts"; @@ -196,12 +197,18 @@ async function assertSafeAssetParent(opts: { readonly uid: number; }): Promise { const parent = dirname(opts.path); + const checked = new Set(); if ( - checkReplaceableChain({ path: parent, uid: opts.uid, hackHome: null }) !== - null + checkReplaceableChain({ + path: parent, + uid: opts.uid, + hackHome: null, + checked, + }) !== null ) { throw new Error("Unsafe directory chain"); } + await assertAutostartPathAclsTrusted(checked); let current = resolve(opts.root); for (const component of [ "", @@ -239,6 +246,7 @@ async function readCurrentAsset(opts: { ); try { const before = await file.stat(); + await assertAutostartPathAclsTrusted(new Set([opts.asset.path])); if ( !before.isFile() || before.uid !== opts.uid || @@ -314,6 +322,7 @@ async function publishFreshAsset(opts: { await assertSafeAssetParent({ ...opts, path: asset.path }); const staged = await file.stat(); const stagedPath = await lstat(staging); + await assertAutostartPathAclsTrusted(new Set([staging])); if ( !staged.isFile() || staged.uid !== opts.uid || diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index 1531ed583..691d24d6f 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -13,9 +13,11 @@ import { } from "../constants.ts"; import { assertAutostartCheckoutTrusted } from "./autostart-checkout-trust.ts"; import { hasOnlyEngineManagedMounts } from "./autostart-compose-mounts.ts"; +import { assertAutostartPathAclsTrusted } from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; import { resolveGlobalConfigPath } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; +import { inspectLegacyComposeAdoptionSelection } from "./native-compose-adoption-marker.ts"; import { checkReplaceableChain } from "./path-trust.ts"; import { findProjectContextAtRoot, @@ -29,7 +31,8 @@ export type AutostartStartPolicyReason = | "command_provider_requires_manual_start" | "compose_inputs_require_manual_start" | "checkout_untrusted" - | "pending_env_migration"; + | "pending_env_migration" + | "adopted_project_requires_manual_start"; export type AutostartStartPolicyOptions = { readonly repoRoot: string; readonly projectDir: string; @@ -44,7 +47,7 @@ const HOST_CONTROL_ENV_KEY = /^(?:COMPOSE_|GIT_|DOCKER_|HACK_|XDG_|LD_|DYLD_|BUN_|HOME$|USER$|LOGNAME$|TMPDIR$|TMP$|TEMP$|SHELL$|PATH$|NODE_OPTIONS$|NODE_PATH$|BASH_ENV$|ENV$|SHELLOPTS$|IFS$|CDPATH$|GCONV_PATH$|PYTHON(?:PATH|HOME|STARTUP|INSPECT)$|RUBYOPT$|RUBYLIB$|PERL5OPT$|PERL5LIB$)/; const DEPENDENCY_INSTALL = /(?:^|\s)(?:bun|npm|pnpm|yarn|uv|pip)(?:\s+run)?\s+(?:install|ci|sync)(?:\s|$)/i; -type Budget = { remaining: number }; +type Budget = { remaining: number; readonly checked: Set }; /** Read-only unattended admission. It never resolves a secret, provider or command. */ export async function autostartStartPolicyRefusal( @@ -56,6 +59,12 @@ export async function autostartStartPolicyRefusal( return "checkout_untrusted"; } try { + const adoption = await inspectLegacyComposeAdoptionSelection({ + projectRoot: opts.repoRoot, + }); + if (["active", "pending"].includes(adoption ?? "")) { + return "adopted_project_requires_manual_start"; + } if ( resolve(opts.repoRoot, opts.projectDirName) !== resolve(opts.projectDir) ) { @@ -68,18 +77,8 @@ export async function autostartStartPolicyRefusal( if (!context) { return "compose_inputs_require_manual_start"; } - const budget = { remaining: MAX_TOTAL_BYTES }; - const jsonPath = resolve(opts.projectDir, PROJECT_CONFIG_FILENAME); - const json = await readPolicyText(jsonPath, budget); - const path = - json === null - ? resolve(opts.projectDir, PROJECT_CONFIG_LEGACY_FILENAME) - : jsonPath; - const text = json ?? (await readPolicyText(path, budget)); - let raw: unknown = {}; - if (text !== null) { - raw = json === null ? Bun.TOML.parse(text) : JSON.parse(text); - } + const budget = { remaining: MAX_TOTAL_BYTES, checked: new Set() }; + const { raw, path } = await readProjectPolicyConfig(opts, budget); const config = parseProjectConfigRecord(raw, path); if (config.parseError || !isRecord(raw)) { return "compose_inputs_require_manual_start"; @@ -110,7 +109,9 @@ export async function autostartStartPolicyRefusal( if (composeReason) { return composeReason; } - return await envPolicyReason({ ...opts, budget }); + const envReason = await envPolicyReason({ ...opts, budget }); + await assertAutostartPathAclsTrusted(budget.checked); + return envReason; } catch { return "compose_inputs_require_manual_start"; } @@ -331,6 +332,24 @@ function environmentScopeReason( return null; } +async function readProjectPolicyConfig( + opts: AutostartStartPolicyOptions, + budget: Budget +) { + const jsonPath = resolve(opts.projectDir, PROJECT_CONFIG_FILENAME); + const json = await readPolicyText(jsonPath, budget); + const path = + json === null + ? resolve(opts.projectDir, PROJECT_CONFIG_LEGACY_FILENAME) + : jsonPath; + const text = json ?? (await readPolicyText(path, budget)); + let raw: unknown = {}; + if (text !== null) { + raw = json === null ? Bun.TOML.parse(text) : JSON.parse(text); + } + return { raw, path }; +} + async function readPolicyText( path: string, budget: Budget @@ -342,6 +361,7 @@ async function readPolicyText( path: dirname(path), uid, hackHome: null, + checked: budget.checked, }) !== null ) { throw new Error("Unsafe input parent"); @@ -396,6 +416,7 @@ async function readPolicyText( throw new Error("Input changed"); } budget.remaining -= length; + budget.checked.add(path); return new TextDecoder("utf-8", { fatal: true }).decode( bytes.subarray(0, length) ); diff --git a/src/lib/autostart-trust-mounts.ts b/src/lib/autostart-trust-mounts.ts index d7b901a3d..a5e34633b 100644 --- a/src/lib/autostart-trust-mounts.ts +++ b/src/lib/autostart-trust-mounts.ts @@ -3,6 +3,7 @@ import { X509Certificate } from "node:crypto"; import { constants } from "node:fs"; import { open } from "node:fs/promises"; import { dirname, resolve } from "node:path"; +import { assertAutostartPathAclsTrusted } from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; import { isRecord } from "./guards.ts"; @@ -63,11 +64,13 @@ async function authenticateCertificates(path: string): Promise { if (uid === undefined) { throw new Error("Unavailable owner"); } + const checked = new Set(); const parent = walkReplaceableChain({ path: dirname(path), uid, hackHome: null, allowMissing: true, + checked, }); if ("error" in parent) { throw new Error("Unsafe certificate parent"); @@ -98,6 +101,8 @@ async function authenticateCertificates(path: string): Promise { ) { throw new Error("Unsafe certificate file"); } + checked.add(path); + await assertAutostartPathAclsTrusted(checked); const bytes = new Uint8Array(before.size + 1); let length = 0; while (length < bytes.length) { diff --git a/src/lib/native-compose-adoption-command.ts b/src/lib/native-compose-adoption-command.ts index ba56fb10f..4e5882036 100644 --- a/src/lib/native-compose-adoption-command.ts +++ b/src/lib/native-compose-adoption-command.ts @@ -1,6 +1,7 @@ import { dirname, resolve } from "node:path"; import { CliUsageError } from "../cli/command.ts"; import { parseProjectSelectorOption } from "../cli/project-selector.ts"; +import { assertAutostartAdoptionAllowed } from "./autostart-refusal.ts"; import { HackCliError } from "./cli-result.ts"; import { resolveComposeStartupTimeoutMs } from "./compose-startup-budget.ts"; import type { LegacyComposeVerifiedBinding } from "./native-compose-adoption-binding.ts"; @@ -325,6 +326,7 @@ export async function tryLegacyComposeAdoptedCommand( if (!projectRoot) { return null; } + assertAutostartAdoptionAllowed(); requireNativeComposeBackend({ backend: process.env.HACK_RUNTIME_BACKEND }); validate(options); const controller = new AbortController(), diff --git a/src/lib/projects-registry.ts b/src/lib/projects-registry.ts index e98203ac2..5a1b2f201 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -4,6 +4,7 @@ import { constants } from "node:fs"; import { type FileHandle, open, realpath } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { GLOBAL_PROJECTS_REGISTRY_FILENAME } from "../constants.ts"; +import { assertAutostartFileAclsTrusted } from "./autostart-path-acl.ts"; import { isAutostartManualStartReason } from "./autostart-refusal.ts"; import { HackCliError } from "./cli-result.ts"; import { resolveGlobalHackDir } from "./config-paths.ts"; @@ -260,6 +261,10 @@ async function readTrustedAutostartRegistryText( } try { const info = await file.stat(); + await assertAutostartFileAclsTrusted({ + path, + uid: process.getuid?.() ?? -1, + }); if ( !isTrustedAutostartRegistryFile({ regular: info.isFile(), diff --git a/tests/autostart-executable-trust.test.ts b/tests/autostart-executable-trust.test.ts index 260ec2ad0..b37489305 100644 --- a/tests/autostart-executable-trust.test.ts +++ b/tests/autostart-executable-trust.test.ts @@ -98,3 +98,78 @@ test("empty or relative lookup entries refuse with fixed value-free errors", asy expect(String(error)).not.toContain("private-sentinel"); } }); + +test("Darwin ACL inspection includes lookup ancestors, programs and symlink target chains", async () => { + const targetDir = join(root, "targets"); + await mkdir(targetDir, { mode: 0o700 }); + const target = join(targetDir, "helper"); + await writeFile(target, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + await symlink(target, join(bin, "helper")); + const inspected = new Set(); + await assertAutostartExecutablePathTrusted( + { path: `${bin}:${join(root, "missing", "bin")}` }, + { + platform: "darwin", + inspect: async (paths, timeoutMs) => { + expect(timeoutMs).toBeGreaterThan(0); + expect(timeoutMs).toBeLessThanOrEqual(3000); + for (const path of paths) { + inspected.add(path); + } + return { + exitCode: 0, + stdout: + " 0: group:everyone deny delete\n 1: user:reader allow read,execute\n", + }; + }, + } + ); + for (const path of [ + "/", + root, + bin, + program, + join(bin, "helper"), + targetDir, + target, + ]) { + expect(inspected.has(path)).toBe(true); + } + expect(inspected.has(join(root, "missing"))).toBe(false); +}); + +for (const stdout of [ + " 0: user:other allow write\n", + " 0: group:other allow add_file,add_subdirectory\n", + " 0: user:other allow unknown_right\n", + " 0: malformed ACL\n", +]) { + test(`Darwin mutation or unknown ACL refuses without exposing output: ${stdout.trim()}`, async () => { + await expect( + assertAutostartExecutablePathTrusted( + { path: bin }, + { platform: "darwin", inspect: async () => ({ exitCode: 0, stdout }) } + ) + ).rejects.toMatchObject({ + code: "E_PERMISSION", + detail: { reason: "executable_path_untrusted" }, + }); + expect((await Bun.file(program).stat()).mode & 0o777).toBe(0o700); + }); +} + +test("Darwin ACL probe failures refuse with closed diagnostics", async () => { + for (const exitCode of [1, 124, 125]) { + await expect( + assertAutostartExecutablePathTrusted( + { path: bin }, + { + platform: "darwin", + inspect: async () => ({ exitCode, stdout: "private-sentinel" }), + } + ) + ).rejects.toThrow( + "Automatic startup refused an unsafe executable search path." + ); + } +}); diff --git a/tests/autostart-path-acl.test.ts b/tests/autostart-path-acl.test.ts new file mode 100644 index 000000000..14c4540d6 --- /dev/null +++ b/tests/autostart-path-acl.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rootCertificates } from "node:tls"; +import { assertAutostartCheckoutTrusted } from "../src/lib/autostart-checkout-trust.ts"; +import { assertAutostartDockerConfigTrusted } from "../src/lib/autostart-docker-trust.ts"; +import * as acl from "../src/lib/autostart-path-acl.ts"; +import { withAutostartProjectLock } from "../src/lib/autostart-project-lock.ts"; +import { secureAutostartRoutingAssets } from "../src/lib/autostart-routing-assets.ts"; +import { autostartStartPolicyRefusal } from "../src/lib/autostart-start-policy.ts"; +import { resolveAutostartTrustedTlsBinds } from "../src/lib/autostart-trust-mounts.ts"; +import { readProjectsRegistry } from "../src/lib/projects-registry.ts"; + +let root = ""; +let projectDir = ""; +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "hack-acl-boundary-")); + projectDir = join(root, ".hack"); + await mkdir(projectDir, { mode: 0o700 }); + await writeFile(join(projectDir, "hack.config.json"), '{"name":"fixture"}', { + mode: 0o600, + }); + await writeFile( + join(projectDir, "docker-compose.yml"), + "services:\n app:\n image: example.invalid/app\n", + { mode: 0o600 } + ); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +test("sync ACL checks use bounded batches, permit read-only inheritance and refuse unknown mutation", () => { + const paths = new Set( + Array.from({ length: 129 }, (_, i) => `/fixture/path-${i}`) + ); + const batches: number[] = []; + acl.assertAutostartPathAclsTrustedSync(paths, { + platform: "darwin", + inspectSync: (entries, timeoutMs) => { + batches.push(entries.length); + expect(timeoutMs).toBeLessThanOrEqual(3000); + return { + exitCode: 0, + stdout: + " 0: group:readers allow read,readattr,file_inherit,directory_inherit,inherited\n", + }; + }, + }); + expect(batches).toEqual([128, 1]); + for (const result of [ + { exitCode: 0, stdout: " 0: user:other allow write\n" }, + { exitCode: 124, stdout: "" }, + ]) { + expect(() => + acl.assertAutostartPathAclsTrustedSync(paths, { + platform: "darwin", + inspectSync: () => result, + }) + ).toThrow(); + } + expect(() => + acl.assertAutostartPathAclsTrustedSync( + new Set(Array.from({ length: 16_385 }, (_, i) => `/fixture/${i}`)), + { + platform: "darwin", + inspectSync: () => { + throw new Error("Oversized inventory must not run a probe"); + }, + } + ) + ).toThrow("exceeds bound"); +}); + +test("descriptor ACL inventory includes the file and full parent chain", async () => { + const path = join(projectDir, "hack.config.json"); + await acl.assertAutostartFileAclsTrusted( + { path, uid: process.getuid?.() ?? -1 }, + { + platform: "darwin", + inspect: async (paths) => { + expect(paths).toContain("/"); + expect(paths).toContain(root); + expect(paths).toContain(projectDir); + expect(paths).toContain(path); + return { exitCode: 0, stdout: "" }; + }, + } + ); +}); + +test("checkout and Docker plugin trust refuse ACL mutation on their complete inventories", async () => { + const pluginDir = join(root, ".docker", "cli-plugins"); + await mkdir(pluginDir, { recursive: true, mode: 0o700 }); + const plugin = join(pluginDir, "docker-compose"); + await writeFile(plugin, "synthetic plugin", { mode: 0o700 }); + const inventories: Set[] = []; + const audit = spyOn( + acl, + "assertAutostartPathAclsTrustedSync" + ).mockImplementation((paths) => { + inventories.push(new Set(paths)); + throw new Error("Synthetic mutation ACL"); + }); + try { + expect(() => + assertAutostartCheckoutTrusted({ repoRoot: root, projectDir }) + ).toThrow("Autostart cannot trust"); + expect(() => + assertAutostartDockerConfigTrusted({ + home: root, + systemPluginDirectories: [], + }) + ).toThrow(); + expect(inventories[0]?.has(join(projectDir, "hack.config.json"))).toBe( + true + ); + expect(inventories[1]?.has(plugin)).toBe(true); + expect( + inventories.every((paths) => paths.has("/") && paths.has(root)) + ).toBe(true); + } finally { + audit.mockRestore(); + } +}); + +test("policy, TLS and routing ACL refusals happen before effects", async () => { + const pki = join(root, "caddy", "pki"); + await mkdir(pki, { recursive: true, mode: 0o700 }); + await writeFile( + join(pki, "caddy-local-authority.crt"), + rootCertificates[0] ?? "", + { mode: 0o644 } + ); + const audit = spyOn(acl, "assertAutostartPathAclsTrusted").mockImplementation( + async () => { + throw new Error("Synthetic mutation ACL"); + } + ); + try { + expect( + await autostartStartPolicyRefusal({ + repoRoot: root, + projectDir, + projectDirName: ".hack", + globalConfigPath: join(root, "global.json"), + }) + ).toBe("compose_inputs_require_manual_start"); + await expect( + resolveAutostartTrustedTlsBinds({ hackHome: root }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + await expect( + secureAutostartRoutingAssets({ + root, + bindAddress: null, + defaultDomain: "hack", + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(audit).toHaveBeenCalledTimes(3); + } finally { + audit.mockRestore(); + } +}); + +test("registry reads and startup locks fail closed on descriptor ACL refusal", async () => { + const registryPath = join(projectDir, "projects.json"); + await writeFile(registryPath, '{"version":1,"projects":[]}', { mode: 0o600 }); + const audit = spyOn(acl, "assertAutostartFileAclsTrusted").mockImplementation( + async () => { + throw new Error("Synthetic mutation ACL"); + } + ); + try { + await expect( + readProjectsRegistry({ registryPath, requireAutostartTrust: true }) + ).rejects.toThrow(); + await expect( + withAutostartProjectLock({ + registryPath, + scope: { + projectId: "fixture", + composeProject: "fixture", + branch: null, + }, + run: () => { + throw new Error("Unsafe lock body must not run"); + }, + }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + expect(audit).toHaveBeenCalledTimes(2); + } finally { + audit.mockRestore(); + } +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index f027584ca..26bab0934 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -1169,30 +1169,35 @@ test("repairing an executable path permits a first attempt in the same boot", as expect(starts.map((start) => start.id)).toEqual(["alpha"]); }); -test("manual-start policy refuses before engine access without claims or failed progress", async () => { - await seed(["alpha"]); - deps = { - ...deps, - projectRefusal: async () => "host_commands_require_manual_start", - }; - await run(); - expect(events).toEqual([]); - expect(starts).toEqual([]); - expect( - (await readProjectsRegistry({ registryPath })).projects[0]?.autostart - ?.lastAttemptBootId - ).toBeNull(); - expect((await readAutostartGlobal({ registryPath })).loginRun.failed).toEqual( - [] - ); - expect((await results())[0]?.result).toMatchObject({ - status: "skipped", - reason: "host_commands_require_manual_start", +for (const reason of [ + "host_commands_require_manual_start", + "adopted_project_requires_manual_start", +]) { + test(`manual-start policy ${reason} refuses before engine access without claims or failed progress`, async () => { + await seed(["alpha"]); + deps = { + ...deps, + projectRefusal: async () => reason, + }; + await run(); + expect(events).toEqual([]); + expect(starts).toEqual([]); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ?.lastAttemptBootId + ).toBeNull(); + expect( + (await readAutostartGlobal({ registryPath })).loginRun.failed + ).toEqual([]); + expect((await results())[0]?.result).toMatchObject({ + status: "skipped", + reason, + }); + deps = { ...deps, projectRefusal: async () => null }; + await run(); + expect(starts.map((start) => start.id)).toEqual(["alpha"]); }); - deps = { ...deps, projectRefusal: async () => null }; - await run(); - expect(starts.map((start) => start.id)).toEqual(["alpha"]); -}); +} test("late child manual-start refusal releases only its unstarted claim and remains neutral", async () => { await seed(["alpha"]); diff --git a/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts b/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts index 68667b39f..9f6122a9a 100644 --- a/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts +++ b/tests/e2e/scenarios/native-compose-adoption-dependency-inputs.ts @@ -1,6 +1,51 @@ import { join } from "node:path"; +import { HACK_ERROR_CODES } from "../../../src/lib/cli-result.ts"; import { isRecord } from "../../../src/lib/guards.ts"; +const COMMAND_STAGES = new Set([ + "unspecified", + "bootstrap-original", + "dependency-preview", + "dependency-adopt-recovery", + "dependency-down-recovery", + "dependency-second-adopt", + "dependency-up", +]); + +/** Fixed stage, exit and known code only: never command arguments, messages or values. */ +export function adoptionFixtureCommandFailure(opts: { + readonly stage: string; + readonly exitCode: number; + readonly timedOut: boolean; + readonly stdout: string; +}): string { + let code = "unavailable"; + try { + const envelope: unknown = + opts.stdout.length <= 65_536 ? JSON.parse(opts.stdout) : null; + const error = + isRecord(envelope) && isRecord(envelope.error) + ? envelope.error + : undefined; + if ( + typeof error?.code === "string" && + HACK_ERROR_CODES.some((known) => known === error.code) + ) { + code = error.code; + } + } catch { + // A non-JSON engine response contributes no content to the public diagnostic. + } + const stage = COMMAND_STAGES.has(opts.stage) ? opts.stage : "unspecified"; + const exit = + Number.isSafeInteger(opts.exitCode) && + opts.exitCode >= 0 && + opts.exitCode <= 255 + ? opts.exitCode + : -1; + return `Expected successful isolated adoption fixture command: stage=${stage} exit=${exit} timedOut=${opts.timedOut === true} code=${code}; values omitted`; +} + const CONTAINER_ID = /^[a-f0-9]{64}$/; export const adoptionDependencyHealthcheck = { diff --git a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts index ac4a56a9f..f5155f099 100644 --- a/tests/e2e/scenarios/native-compose-adoption-worktrees.ts +++ b/tests/e2e/scenarios/native-compose-adoption-worktrees.ts @@ -55,6 +55,7 @@ import { } from "./native-compose-adoption-build-inputs.ts"; import { adoptionDependencyHealthcheck, + adoptionFixtureCommandFailure, assertAdoptionDependencyControl, assertAdoptionDependencyHealthcheck, } from "./native-compose-adoption-dependency-inputs.ts"; @@ -575,12 +576,16 @@ function privateReport(result: CliResult) { refused(); } } -function successful(result: CliResult) { +function successful(result: CliResult, stage = "unspecified") { privateReport(result); expect({ that: !result.timedOut && result.exitCode === 0, - message: - "Expected successful isolated adoption fixture command; values omitted", + message: adoptionFixtureCommandFailure({ + stage, + exitCode: result.exitCode, + timedOut: result.timedOut, + stdout: result.stdout, + }), }); return result; } @@ -2346,7 +2351,7 @@ export async function bootstrapOriginal(h: FixtureRuntime, instance: Instance) { resources: captured, source: originalSource, }); - successful(started); + successful(started, "bootstrap-original"); if ( captured.container.length !== (instance.routing ? 3 : 2) || captured.volume.length !== 1 || @@ -3407,7 +3412,7 @@ const child=Bun.spawn([engine,...args],{stdin:'inherit',stdout:'inherit',stderr: refused(); } } else { - successful(result); + successful(result, "dependency-up"); } } @@ -3841,7 +3846,8 @@ async function runDependencyWorktrees( "--dry-run", "--stop", "--json", - ]) + ]), + "dependency-preview" ); if (object(preview.stdout).complete !== true) { refused(); @@ -3860,7 +3866,8 @@ async function runDependencyWorktrees( "--recover", "--stop", "--json", - ]) + ]), + "dependency-adopt-recovery" ); await h.assertStopped(h.first); refusedPreview(await h.cli(h.first, ["up", "db", "--detach", "--json"])); @@ -3875,7 +3882,10 @@ async function runDependencyWorktrees( refused(); } await dependencyCandidateDriftRefusal(h); - successful(await h.cli(h.first, ["down", "--recover", "--json"])); + successful( + await h.cli(h.first, ["down", "--recover", "--json"]), + "dependency-down-recovery" + ); await h.assertStopped(h.first); await h.check(h.second); await dependencyFixtureUp(h, h.first, false); @@ -3885,7 +3895,8 @@ async function runDependencyWorktrees( await rollbackDependencyInstance(h, h.first); await h.check(h.second); successful( - await h.cli(h.second, ["config", "adopt", "--stop", "--json"]) + await h.cli(h.second, ["config", "adopt", "--stop", "--json"]), + "dependency-second-adopt" ); await h.assertStopped(h.second); await h.check(h.first); diff --git a/tests/native-compose-adoption-dependency-fixture.test.ts b/tests/native-compose-adoption-dependency-fixture.test.ts index 1568c77ff..3bd41777c 100644 --- a/tests/native-compose-adoption-dependency-fixture.test.ts +++ b/tests/native-compose-adoption-dependency-fixture.test.ts @@ -4,6 +4,7 @@ import { legacyComposeAdoptionContainerInspectFormat } from "../src/lib/native-c import { adoptionDependencyHealthcheck, adoptionDependencyReadAllowed, + adoptionFixtureCommandFailure, assertAdoptionDependencyControl, assertAdoptionDependencyHealthcheck, assertAdoptionDependencyStart, @@ -34,6 +35,34 @@ const options = { const refusal = "Adoption dependency fixture ordering check failed; values omitted."; +test("fixture failure diagnostics keep only closed stages, exit status and known error codes", () => { + const message = adoptionFixtureCommandFailure({ + stage: "dependency-preview", + exitCode: 1, + timedOut: false, + stdout: JSON.stringify({ + error: { + code: "E_CONFIG_INVALID", + message: "private-sentinel", + detail: { path: "private-sentinel" }, + }, + }), + }); + expect(message).toContain( + "stage=dependency-preview exit=1 timedOut=false code=E_CONFIG_INVALID" + ); + expect(message).not.toContain("private-sentinel"); + const unknown = adoptionFixtureCommandFailure({ + stage: "private-sentinel", + exitCode: Number.NaN, + timedOut: false, + stdout: JSON.stringify({ error: { code: "private-sentinel" } }), + }); + expect(unknown).toContain("stage=unspecified exit=-1"); + expect(unknown).toContain("code=unavailable"); + expect(unknown).not.toContain("private-sentinel"); +}); + test("missing fixture controls retain fixed stage and exit diagnostics instead of ENOENT or private CLI output", () => { expect(() => assertAdoptionDependencyControl({ diff --git a/tests/native-compose-adoption-generation-retained.test.ts b/tests/native-compose-adoption-generation-retained.test.ts index e1c328280..31fe4bca0 100644 --- a/tests/native-compose-adoption-generation-retained.test.ts +++ b/tests/native-compose-adoption-generation-retained.test.ts @@ -1,6 +1,11 @@ import { test as boundedTest, expect, spyOn } from "bun:test"; -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { + resolveFinalAutostartDownTarget, + withAutostartCommandLock, +} from "../src/commands/project-autostart-intent.ts"; +import { autostartStartPolicyRefusal } from "../src/lib/autostart-start-policy.ts"; import { tryLegacyComposeAdoptedCommand } from "../src/lib/native-compose-adoption-command.ts"; import { runLegacyComposeRetainedOperation } from "../src/lib/native-compose-adoption-execution.ts"; import { openLegacyComposeAdoptedGenerationStore } from "../src/lib/native-compose-adoption-generation.ts"; @@ -8,6 +13,7 @@ import * as adoptionRuntime from "../src/lib/native-compose-adoption-runtime.ts" import { captureAdoptionDependencyFirstPrepare } from "./e2e/scenarios/native-compose-adoption-dependency-staged-read.ts"; import { restoreEnv } from "./helpers/env.ts"; import { createAdoptionGenerationFixture } from "./helpers/native-compose-adoption-generation-fixture.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; const context = createAdoptionGenerationFixture(); const { @@ -494,6 +500,7 @@ boundedTest( join(context.projectRoot, ".hack/hack.project.json"), "utf8" ); + await expectAdoptionLoginRefused(); context.fixture.sourceRace = true; await save(); await expect( @@ -505,6 +512,10 @@ boundedTest( ).rejects.toThrow("values omitted"); expect(await mutationCommands()).toEqual([["container", "start", ID]]); expect((await readReceipt()).pendingOperation.operation).toBe("start"); + await expectAdoptionLoginRefused(); + expect( + await resolveFinalAutostartDownTarget({ cwd: context.projectRoot }) + ).toBeUndefined(); context.fixture = JSON.parse( await readFile(join(context.root, "fixture.json"), "utf8") ); @@ -515,10 +526,15 @@ boundedTest( candidate ); expect( - await tryLegacyComposeAdoptedCommand({ + await withAutostartCommandLock({ + action: "down", cwd: context.projectRoot, - operation: "down", - recover: true, + run: () => + tryLegacyComposeAdoptedCommand({ + cwd: context.projectRoot, + operation: "down", + recover: true, + }), }) ).toBe(0); expect((await mutationCommands()).slice(1)).toEqual([ @@ -535,6 +551,78 @@ boundedTest( retainedOperationCaseTimeout(2) ); +/** Exercise daemon admission, CLI serialization and the actual retained dispatch backstop. */ +async function expectAdoptionLoginRefused() { + const before = await readFile(join(context.root, "commands"), "utf8"); + const home = privateCliEnv({ root: context.root }).HOME; + if (!home) { + throw new Error("Fixture HOME required"); + } + await mkdir(join(home, ".hack"), { mode: 0o700, recursive: true }); + await writeFile( + join(home, ".hack/projects.json"), + JSON.stringify({ + version: 1, + projects: [ + { + id: "fixture", + name: "fixture", + repoRoot: context.projectRoot, + projectDir: join(context.projectRoot, ".hack"), + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + }, + ], + }), + { mode: 0o600 } + ); + expect( + await autostartStartPolicyRefusal({ + repoRoot: context.projectRoot, + projectDir: join(context.projectRoot, ".hack"), + projectDirName: ".hack", + }) + ).toBe("adopted_project_requires_manual_start"); + const prior = { + HOME: process.env.HOME, + HACK_AUTOSTART_LOGIN: process.env.HACK_AUTOSTART_LOGIN, + HACK_AUTOSTART_PROJECT_ID: process.env.HACK_AUTOSTART_PROJECT_ID, + }; + try { + process.env.HOME = home; + process.env.HACK_AUTOSTART_LOGIN = "1"; + process.env.HACK_AUTOSTART_PROJECT_ID = "fixture"; + const refusal = { + code: "E_PERMISSION", + detail: { reason: "adopted_project_requires_manual_start" }, + }; + await expect( + resolveFinalAutostartDownTarget({ cwd: context.projectRoot }) + ).rejects.toMatchObject(refusal); + await expect( + withAutostartCommandLock({ + cwd: context.projectRoot, + action: "up", + run: () => { + throw new Error("Unattended dispatch must not run"); + }, + }) + ).rejects.toMatchObject(refusal); + await expect( + tryLegacyComposeAdoptedCommand({ + cwd: context.projectRoot, + operation: "up", + detach: true, + }) + ).rejects.toMatchObject(refusal); + } finally { + for (const [key, value] of Object.entries(prior)) { + restoreEnv(key, value); + } + } + expect(await readFile(join(context.root, "commands"), "utf8")).toBe(before); +} + boundedTest( "v5 final unhealthy observation cannot acknowledge a successful effect", async () => { diff --git a/tests/runtime-backend-unattended.test.ts b/tests/runtime-backend-unattended.test.ts index f220424bb..4397e3b96 100644 --- a/tests/runtime-backend-unattended.test.ts +++ b/tests/runtime-backend-unattended.test.ts @@ -62,6 +62,8 @@ for (const scenario of [ "safe-existing", "safe-missing", "generated-bind", + "trusted-unicode-bind", + "invalid-utf8", ] as const) { test(`real unattended resolved-storage probes handle ${scenario} before any unsafe up`, async () => { const root = await mkdtemp(join(tmpdir(), "hack-storage-probe-")); @@ -81,10 +83,13 @@ for (const scenario of [ image: "example.invalid/app", environment: { PRIVATE_SENTINEL: "synthetic-model-value" }, volumes: [ - scenario === "generated-bind" + scenario === "generated-bind" || scenario === "trusted-unicode-bind" ? { type: "bind", - source: "/host/private", + source: + scenario === "trusted-unicode-bind" + ? "/fixture/unicode-é/.hack/caddy/pki/authority.crt" + : "/host/private", target: "/data", read_only: true, } @@ -110,7 +115,7 @@ for (const scenario of [ join(bin, "docker"), `#!${process.execPath} const args=process.argv.slice(2); -if(args.includes("config")){process.stdout.write(${JSON.stringify(JSON.stringify(model))});process.exit(0);} +if(args.includes("config")){process.stdout.write(${scenario === "invalid-utf8" ? "Buffer.from([0xff])" : JSON.stringify(JSON.stringify(model))});process.exit(0);} if(args[0]==="image"){process.stdout.write("sha256:${"a".repeat(64)}");process.exit(0);} if(args[0]==="volume"){ if(${JSON.stringify(scenario)}==="safe-missing" && !(await Bun.file(${JSON.stringify(called)}).exists())){process.stderr.write("Error response from daemon: get fixture_data: no such volume\\n");process.exit(1);} @@ -127,6 +132,13 @@ process.exit(99); env: privateCliEnv({ root, env: { PATH: bin } }), detach: true, unattended: true, + unattendedTrustedBinds: async () => [ + { + source: "/fixture/unicode-é/.hack/caddy/pki/authority.crt", + target: "/data", + readOnly: true, + }, + ], imageTimeoutMs: 10_000, startupTimeoutMs: 10_000, }); @@ -136,6 +148,9 @@ process.exit(99); detail: { reason: "compose_inputs_require_manual_start" }, }); expect(await Bun.file(called).exists()).toBe(false); + } else if (scenario === "invalid-utf8") { + expect(await action).toBe(1); + expect(await Bun.file(called).exists()).toBe(false); } else { expect(await action).toBe(0); expect((await Bun.file(called).text()).trim().split("\n")).toHaveLength( From 6ccef20c82791dfc5557634ef91f668bbfd465a0 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:58:49 +0000 Subject: [PATCH 20/44] fix(autostart): settle CLI cancellation and reject unresolved merges --- docs/always-on-projects.md | 7 + docs/versions/v5/getting-started.md | 1 + src/commands/autostart.ts | 83 ++++++----- src/lib/autostart-start-policy.ts | 41 +++++- tests/autostart-cli-cancellation.test.ts | 134 ++++++++++++++++++ tests/autostart-start-policy.test.ts | 42 ++++++ .../tla/autostart-start-stop/COVERAGE.md | 4 + 7 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 tests/autostart-cli-cancellation.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index d460c6fa1..e521cbee1 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -103,6 +103,7 @@ configuration changes after the daemon's first observation. Command/cloud env providers also require a manual start; built-in local encrypted env layers remain supported without keychain fallback. Managed values that change Compose selection, Docker lookup or host process execution are refused without resolving secrets. +Unresolved YAML merge keys at any depth require manual startup. Compose `build`, `include`, `extends`, `env_file` and external service-provider declarations require manual startup in this slice. Images must already be present locally: both Compose phases use `--no-build --pull never` and disable implicit @@ -235,6 +236,12 @@ A start has a 120-second bound, then receives TERM and a five-second grace befor KILL. Detached Compose phases forward cancellation and settle their own process groups before releasing startup ownership. Partial startup remains possible; inspect and recover with `hack up`. +An explicit `hack autostart run` forwards SIGINT/SIGTERM into the pass and waits +for active startup children to settle before exiting with the signal status. +Cancellation stops later dispatches and does not print a successful completion; +it cannot undo a request Docker has already accepted. +This explicit-command guard covers SIGINT/SIGTERM, not terminal-loss SIGHUP; +use the installed login service for unattended operation. `HACK_DAEMON_AUTOSTART_DISABLE=1` disables this pass as well as implicit daemon startup. Test harnesses and the isolated `hack-v5` launcher set it; those runs do diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 10246964e..6c037d819 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -75,6 +75,7 @@ locally. Hooks, host helpers, command providers and image builds require manual Retained projects created by `hack config adopt` also require manual starts. Host-file/source-code bind mounts also require manual starts. Verified default engine volumes and Hack's authenticated read-only TLS mounts remain supported. +Interrupting `hack autostart run` cancels the pass and waits for startup cleanup. Expect the declared services in `ps` and the app's local URL from `open`. If a project has several URLs, choose the app's route. A worker-only project may diff --git a/src/commands/autostart.ts b/src/commands/autostart.ts index 46f502046..09278e93b 100644 --- a/src/commands/autostart.ts +++ b/src/commands/autostart.ts @@ -29,6 +29,7 @@ import { resolveUserHomeDir } from "../lib/home-dir.ts"; import { findProjectContext, sanitizeBranchSlug } from "../lib/project.ts"; import { ProjectInputSelectionError } from "../lib/project-input-selection.ts"; import { PROJECT_SELECTOR_PATTERN } from "../lib/project-name.ts"; +import { withTerminationAbort } from "../lib/project-shared-helpers.ts"; import { getDefaultAutostartGlobal, type RegisteredProject, @@ -377,45 +378,51 @@ const run: CommandHandlerFor = async ({ args }) => { "--engine-wait-ms must be an integer from 1 to 3600000." ); } - const result = await runAutostartAtLogin({ - engineWaitMs, - runtime: { - home: resolveUserHomeDir(), - hackHome: resolveGlobalHackDir(), - serviceOrigin: false, - platform: process.platform, - }, - }); - const registry = await readProjectsRegistry(); - if (args.options.json) { - const global = serializeAutostartGlobal( - registry.autostartGlobal ?? getDefaultAutostartGlobal(), - await readAutostartServiceReady() - ); - emitCliResult({ - result: okResult({ - data: { - action: "run", - waiting_for_engine: result.waitingForEngine, - autostart_global: global, - projects: registry.projects - .filter((project) => project.autostart) - .map((project) => ({ - project_id: project.id, - name: project.name, - autostart: projectAutostartFromGlobal({ project, global }), - })), - }, - }), + return await withTerminationAbort(async (signal) => { + const result = await runAutostartAtLogin({ + signal, + engineWaitMs, + runtime: { + home: resolveUserHomeDir(), + hackHome: resolveGlobalHackDir(), + serviceOrigin: false, + platform: process.platform, + }, }); - } else { - process.stdout.write( - result.waitingForEngine - ? "Autostart is waiting for the engine; no project start was claimed. Retry with: hack autostart run --no-interactive\n" - : "Autostart pass finished. Already attempted projects were not replayed; use hack up -d for manual recovery.\n" - ); - } - return 0; + if (signal.aborted) { + return 1; + } + const registry = await readProjectsRegistry(); + if (args.options.json) { + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); + emitCliResult({ + result: okResult({ + data: { + action: "run", + waiting_for_engine: result.waitingForEngine, + autostart_global: global, + projects: registry.projects + .filter((project) => project.autostart) + .map((project) => ({ + project_id: project.id, + name: project.name, + autostart: projectAutostartFromGlobal({ project, global }), + })), + }, + }), + }); + } else { + process.stdout.write( + result.waitingForEngine + ? "Autostart is waiting for the engine; no project start was claimed. Retry with: hack autostart run --no-interactive\n" + : "Autostart pass finished. Already attempted projects were not replayed; use hack up -d for manual recovery.\n" + ); + } + return 0; + }); }; export const autostartCommand = defineCommand({ diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index 691d24d6f..7eca80ea4 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -190,7 +190,7 @@ function composePolicyReason( !(isRecord(compose) && isRecord(compose.services)) || Object.keys(compose.services).length === 0 || compose.include !== undefined || - compose["<<"] !== undefined || + hasUnresolvedComposeMerge(compose) || !hasOnlyEngineManagedMounts(compose) ) { return "compose_inputs_require_manual_start"; @@ -204,6 +204,45 @@ function composePolicyReason( return null; } +/** Compose must not resolve inputs hidden in a merge the admission parser left intact. */ +function hasUnresolvedComposeMerge(value: unknown): boolean { + const pending: { readonly value: unknown; readonly exit?: boolean }[] = [ + { value }, + ]; + const visited = new Set(); + const active = new Set(); + while (pending.length > 0) { + const frame = pending.pop(); + const current = frame?.value; + if (!(Array.isArray(current) || isRecord(current))) { + continue; + } + if (frame?.exit) { + active.delete(current); + continue; + } + if (active.has(current)) { + return true; + } + if (visited.has(current)) { + continue; + } + visited.add(current); + if (visited.size > 16_384 || Object.hasOwn(current, "<<")) { + return true; + } + active.add(current); + pending.push({ value: current, exit: true }); + for (const child of Object.values(current)) { + if (pending.length >= 16_384) { + return true; + } + pending.push({ value: child }); + } + } + return false; +} + function servicePolicyReason( service: unknown ): AutostartStartPolicyReason | null { diff --git a/tests/autostart-cli-cancellation.test.ts b/tests/autostart-cli-cancellation.test.ts new file mode 100644 index 000000000..ff24b12c2 --- /dev/null +++ b/tests/autostart-cli-cancellation.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +async function checkpoint(path: string, alive: () => boolean) { + const deadline = Date.now() + 8000; + while (!(await Bun.file(path).exists())) { + if (!alive() || Date.now() >= deadline) { + throw new Error("Owned cancellation fixture checkpoint unavailable"); + } + await Bun.sleep(10); + } +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + test(`explicit autostart CLI ${signal} awaits detached startup settlement`, async () => { + const root = await mkdtemp(join(tmpdir(), "hack-autostart-cancel-")); + const env = privateCliEnv({ root }); + const hackHome = join(env.HOME ?? root, ".hack"); + await mkdir(hackHome, { mode: 0o700 }); + const registryPath = join(hackHome, "projects.json"); + await writeFile( + registryPath, + JSON.stringify({ + version: 1, + projects: ["alpha", "beta"].map((name) => ({ + id: name, + name, + repoRoot: join(root, name), + projectDir: join(root, name, ".hack"), + projectDirName: ".hack", + createdAt: "2026-01-01T00:00:00Z", + autostart: { + branch: null, + paused: false, + lastResult: null, + lastAttemptBootId: null, + }, + })), + }), + { mode: 0o600 } + ); + const ready = join(root, "ready"), + stopping = join(root, "stopping"), + settled = join(root, "settled"); + const helper = join(root, "startup.ts"); + await writeFile( + helper, + ` +import { writeFileSync } from "node:fs"; +process.on("SIGTERM", () => { + writeFileSync(${JSON.stringify(stopping)}, "stopping"); + setTimeout(() => { writeFileSync(${JSON.stringify(settled)}, "settled"); process.exit(0); }, 250); +}); +writeFileSync(${JSON.stringify(ready)}, String(process.pid)); +setInterval(() => {}, 60000); +` + ); + const driver = join(root, "driver.ts"); + const modulePath = resolve("src/daemon/autostart.ts"); + await writeFile( + driver, + ` +import { mock } from "bun:test"; +const actual = await import(${JSON.stringify(modulePath)}); +const runPass = actual.runAutostartAtLogin; +mock.module(${JSON.stringify(modulePath)}, () => ({ ...actual, + runAutostartAtLogin: (options) => runPass({ ...options, dependencies: { + bootId: async () => "fixture-boot", now: Date.now, sleep: (ms) => Bun.sleep(ms), + engineReady: async () => true, routingInstalled: async () => false, + startRouting: async () => true, inspect: async () => "stopped", + start: async (_project, signal) => actual.classifyAutostartExit(await actual.runAutostartSubprocess( + [process.execPath, ${JSON.stringify(helper)}], { timeoutMs: 120000, signal, env: process.env } + )), + } }), +})); +const { runCli } = await import(${JSON.stringify(resolve("src/cli/run.ts"))}); +process.exitCode = await runCli(["autostart", "run", "--no-interactive", "--json"]); +` + ); + const proc = Bun.spawn([process.execPath, driver], { + env, + cwd: root, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(proc.stdout).text(), + stderr = new Response(proc.stderr).text(); + let helperPid: number | undefined; + try { + await checkpoint(ready, () => proc.exitCode === null); + helperPid = Number(await readFile(ready, "utf8")); + expect(Number.isSafeInteger(helperPid)).toBe(true); + proc.kill(signal); + await checkpoint(stopping, () => proc.exitCode === null); + expect(proc.exitCode).toBeNull(); + proc.kill(signal); + expect(await proc.exited, await stderr).toBe( + signal === "SIGINT" ? 130 : 143 + ); + expect(await Bun.file(settled).text()).toBe("settled"); + expect(await stdout).toBe(""); + expect(() => process.kill(helperPid ?? -1, 0)).toThrow(); + const registry = await Bun.file(registryPath).json(); + expect(registry.autostartGlobal.loginRun.inProgress).toBe(false); + expect(registry.projects[0].autostart.lastResult).toMatchObject({ + status: "skipped", + reason: "daemon_stopped", + }); + expect(registry.projects[1].autostart.lastAttemptBootId).toBeNull(); + } finally { + if (proc.exitCode === null) { + proc.kill("SIGKILL"); + } + await proc.exited; + if ( + helperPid !== undefined && + Number.isSafeInteger(helperPid) && + helperPid > 1 + ) { + try { + process.kill(-helperPid, "SIGKILL"); + } catch { + /* Owned group already settled. */ + } + } + await Promise.all([stdout, stderr]); + await rm(root, { recursive: true, force: true }); + } + }, 15_000); +} diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts index ba6668f21..8f236660f 100644 --- a/tests/autostart-start-policy.test.ts +++ b/tests/autostart-start-policy.test.ts @@ -147,6 +147,48 @@ test("legacy TOML startup is checked by the same canonical parser", async () => ); }); +test("nested Compose merges cannot hide external execution inputs", async () => { + for (const input of [ + "services:\n web:\n <<: {extends: {file: other.yml, service: base}}\n image: example/web:1\n", + "x-base: &base\n extends: {file: other.yml, service: base}\nservices:\n web:\n <<: *base\n image: example/web:1\n", + "services:\n web:\n <<: {build: .}\n image: example/web:1\n", + "services:\n web:\n <<: {env_file: [other.env]}\n image: example/web:1\n", + ]) { + await writeFile(join(opts.projectDir, "docker-compose.yml"), input, { + mode: 0o600, + }); + expect(await autostartStartPolicyRefusal(opts)).not.toBeNull(); + } + await compose({ + services: { + web: { image: "example/web:1", environment: { "<<": "unresolved" } }, + }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); +}); + +test("shared Compose anchors remain eligible while recursive aliases refuse", async () => { + for (const input of [ + "x-env: &env {APP_MODE: production}\nservices:\n web: {image: example/web:1, environment: *env}\n worker: {image: example/web:1, environment: *env}\n", + "x-base: &base {image: example/web:1, environment: {APP_MODE: production}}\nservices:\n web: {<<: *base}\n worker: {<<: *base}\n", + ]) { + await writeFile(join(opts.projectDir, "docker-compose.yml"), input, { + mode: 0o600, + }); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); + } + await writeFile( + join(opts.projectDir, "docker-compose.yml"), + "x-cycle: &cycle [*cycle]\nservices:\n web: {image: example/web:1}\n", + { mode: 0o600 } + ); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); +}); + test("builds, ambiguous Compose graph sources, external providers and env files never start unattended", async () => { for (const [service, reason] of [ [ diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md index 486f66565..d38718d44 100644 --- a/tests/models/tla/autostart-start-stop/COVERAGE.md +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -65,6 +65,10 @@ volumes, ownership labels, safe missing/default volumes and withdrawn TLS trust. These are implementation regressions; the finite model does not model Docker storage metadata, certificate parsing, privileged engine actors or application code/data inside prepared images and engine-managed volumes. +Explicit CLI signal forwarding is covered by `autostart-cli-cancellation.test.ts`: +SIGINT and SIGTERM stop later dispatches, await a real detached startup child's +cleanup, finish progress and preserve signal exit status. The finite model does +not model operating-system signal delivery or the CLI entry point. Multiple projects, multiple login attempts, reboot, upgrades, global pause, retries, network prompts, native backends and real login-service behavior are outside this model. Component model success does not qualify installed binaries or real login. From b1d417cbb98e731558fa61b22f92fe2a2eba905e Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:11:31 +0000 Subject: [PATCH 21/44] fix(autostart): require fresh reviewed inputs after writable exposure --- docs/always-on-projects.md | 21 +++++++++++++++----- docs/versions/v5/getting-started.md | 2 ++ src/commands/doctor.ts | 2 +- src/lib/autostart-start-policy.ts | 1 + tests/autostart-start-policy.test.ts | 29 ++++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index e521cbee1..aaed5bb81 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -83,11 +83,22 @@ path does not consume a project's attempt for this boot. The daemon keeps its installed PATH and continues normal status observation; automatic-start refusal does not change ordinary daemon operation. The strict policy also applies to Linux user-private groups. Check `hack doctor` -for the affected trust category. After reviewing ownership and who belongs to the -group, remove group/other write only on the intended files and directories with -`chmod go-w `; do not recursively change a shared checkout or -Homebrew installation. Using a private checkout and private tool installation is -an alternative. Hack never changes those permissions automatically. +for the affected trust category. After reviewing ownership and group membership, +`chmod go-w ` can restrict future directory writes; do not +recursively change a shared checkout or Homebrew installation. It does not undo +entry changes made while that directory was writable. +Do not repair formerly writable files by changing modes or ACLs in place: an +already-open writer can still modify that inode. Preserve the old checkout and +create a private checkout from reviewed source, with freshly generated inputs; +review local config and env content before recreating it on new private inodes. +Regenerating inputs in an existing checkout may retain unchanged old inodes and +does not provide this replacement. +Reinstall formerly writable helpers/plugins and recreate reviewed Docker config +on fresh private files too. Merely copying unreviewed old bytes is not a repair. +These checks observe current ownership, permissions and stable reads; they cannot +infer permission history, revoke older writer descriptors or freeze every later +Compose read. Automatic startup requires trusted, reviewed input contents as well +as trusted paths. Hack never changes those permissions or files automatically. Use `umask 022` in shells that run `hack up` so changed generated inputs retain safe modes; with umask 002, a later manual start can recreate group-writable generated files and require another review before automatic startup. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 6c037d819..25453cdcb 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -69,6 +69,8 @@ the per-user daemon with `hack daemon install`. `hack down` pauses its marker; the startup bounds, no-prompt behavior and JSON contract. Automatic startup requires trusted, non-writable-by-others tool and project paths; `hack doctor` reports permission refusals, including app-bundle and Homebrew paths on macOS. +Formerly writable files require reviewed replacement on fresh private files; +changing modes or ACLs in place cannot revoke an older open writer. This slice starts only eligible Compose projects with images already available locally. Hooks, host helpers, command providers and image builds require manual `hack up`; doctor explains those manual-start requirements. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index af3e93e63..41e92ad60 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1991,7 +1991,7 @@ async function checkAutostartLoginService(): Promise { return { name: "autostart login service", status: "warn", - message: `Automatic startup trust prerequisites are not met: ${warnings.join(", ")}. Review ownership and group/other write permissions; see docs/always-on-projects.md. On macOS this can include Homebrew, Docker Desktop or OrbStack links through /Applications. Ordinary daemon operation is unaffected.`, + message: `Automatic startup trust prerequisites are not met: ${warnings.join(", ")}. Review ownership and group/other write permissions; formerly writable files need reviewed replacement on fresh private files, since chmod or ACL changes cannot revoke an older open writer. See docs/always-on-projects.md. On macOS this can include Homebrew, Docker Desktop or OrbStack links through /Applications. Ordinary daemon operation is unaffected.`, }; } let unitPath: string | null = null; diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index 7eca80ea4..2fd71ddd1 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -389,6 +389,7 @@ async function readProjectPolicyConfig( return { raw, path }; } +/** Current inode checks cannot authenticate permission history; formerly writable inputs require reviewed replacement. */ async function readPolicyText( path: string, budget: Budget diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts index 8f236660f..e459844e6 100644 --- a/tests/autostart-start-policy.test.ts +++ b/tests/autostart-start-policy.test.ts @@ -3,8 +3,10 @@ import { chmod, mkdir, mkdtemp, + open, readdir, readFile, + rename, rm, stat, symlink, @@ -169,6 +171,33 @@ test("nested Compose merges cannot hide external execution inputs", async () => ); }); +test("reviewed fresh Compose inputs remain isolated from a retained formerly-writable descriptor", async () => { + const path = join(opts.projectDir, "docker-compose.yml"); + await chmod(path, 0o666); + const stale = await open(path, "r+"); + const reviewed = "services:\n web:\n image: example/web:1\n"; + try { + expect(await autostartStartPolicyRefusal(opts)).toBe("checkout_untrusted"); + await chmod(path, 0o600); + const modified = + "services:\n web:\n image: example/web:1\n privileged: true\n"; + await stale.write(modified, 0, "utf8"); + expect(await readFile(path, "utf8")).toContain("privileged: true"); + await rename(path, `${path}.preserved`); + const fresh = await open(path, "wx", 0o600); + try { + await fresh.writeFile(reviewed); + } finally { + await fresh.close(); + } + await stale.write("changed old inode", 0, "utf8"); + expect(await readFile(path, "utf8")).toBe(reviewed); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); + } finally { + await stale.close(); + } +}); + test("shared Compose anchors remain eligible while recursive aliases refuse", async () => { for (const input of [ "x-env: &env {APP_MODE: production}\nservices:\n web: {image: example/web:1, environment: *env}\n worker: {image: example/web:1, environment: *env}\n", From 3f8b8ec8eaeaaa6bb17d08586e51f595b1cfce9c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:17:37 +0000 Subject: [PATCH 22/44] fix(autostart): record the pinned down target in activity --- src/commands/project.ts | 4 ++-- tests/autostart-down-target.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/commands/project.ts b/src/commands/project.ts index da743b58b..d339ac40f 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -7663,9 +7663,9 @@ async function runDownCommand({ writeDownNotice({ json, level, message }); }, }); - // The activity record names the instance this down actually targets. - noteActivityBranch(branch); } + // Include an already-pinned autostart target as well as implicit reconciliation. + noteActivityBranch(branch); await recordBranchUsageIfNeeded({ project, branch }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; const cachePruneCandidates = diff --git a/tests/autostart-down-target.test.ts b/tests/autostart-down-target.test.ts index 224004fb9..a41279da4 100644 --- a/tests/autostart-down-target.test.ts +++ b/tests/autostart-down-target.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { resolveFinalAutostartDownTarget } from "../src/commands/project-autostart-intent.ts"; +import { readActivity } from "../src/lib/activity-log.ts"; import { withAutostartProjectLock } from "../src/lib/autostart-project-lock.ts"; import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; import { restoreEnv } from "./helpers/env.ts"; @@ -326,6 +327,14 @@ process.exitCode = await runCli(["down", "--json", "--no-interactive"]); expect(downArgs).toContain(ownedComposeProject); } expect(downArgs).not.toContain("example--new-branch"); + const activity = await readActivity({ + path: join(registryDir, "activity.jsonl"), + limit: 10, + }); + expect(activity.at(-1)).toMatchObject({ + action: "down", + branch: ownedBranch, + }); } finally { release.resolve(); await lock; From 38b0f2507456bf05955c90a09949895a1ff6fa7c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 07:32:17 +0000 Subject: [PATCH 23/44] test(cli): preserve native plan state checks with activity logging --- scripts/check-native-acquisition-plan-cli.ts | 15 ++- scripts/check-native-endpoint-plan-cli.ts | 19 +++- scripts/check-native-env-plan-cli.ts | 18 +++- scripts/check-native-host-plan-cli.ts | 15 ++- scripts/check-native-process-plan-cli.ts | 15 ++- scripts/check-native-routing-plan-cli.ts | 18 +++- scripts/lib/native-plan-home-state.ts | 63 +++++++++++++ tests/native-plan-home-state.test.ts | 98 ++++++++++++++++++++ 8 files changed, 251 insertions(+), 10 deletions(-) create mode 100644 scripts/lib/native-plan-home-state.ts create mode 100644 tests/native-plan-home-state.test.ts diff --git a/scripts/check-native-acquisition-plan-cli.ts b/scripts/check-native-acquisition-plan-cli.ts index d38b95270..fea26eeda 100644 --- a/scripts/check-native-acquisition-plan-cli.ts +++ b/scripts/check-native-acquisition-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home-state.ts"; /** Qualify pure acquisition intent in a relocated bundle and a real linked worktree. */ const root = resolve(import.meta.dir, ".."); @@ -264,6 +265,14 @@ try { ], checkout ); + assert( + await nativePlanHomeMatches({ + home, + activityCount: 0, + privateValues: [canary, directory], + }), + "offline planning creates no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach"], checkout, false, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -283,7 +292,11 @@ try { ); } assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, + await nativePlanHomeMatches({ + home, + activityCount: 1, + privateValues: [canary, directory], + }), "planning creates no registry or runtime home state" ); process.stdout.write( diff --git a/scripts/check-native-endpoint-plan-cli.ts b/scripts/check-native-endpoint-plan-cli.ts index 8b98031cd..e0927c53a 100644 --- a/scripts/check-native-endpoint-plan-cli.ts +++ b/scripts/check-native-endpoint-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home-state.ts"; /** Exercise pure endpoint planning through the relocated CLI and real Git linkage. */ const root = resolve(import.meta.dir, ".."); @@ -367,6 +368,14 @@ try { "actual resolution rejects unbound logical reference" ); await authored(checkout, project); + assert( + await nativePlanHomeMatches({ + home, + activityCount: 0, + privateValues: [canary, directory], + }), + "offline planning creates no unexpected home state" + ); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -393,8 +402,14 @@ try { "no generated runtime state" ); } - const homeEntries = await readdir(join(home, ".hack")).catch(() => []); - assert(homeEntries.length === 0, "no registry or runtime home writes"); + assert( + await nativePlanHomeMatches({ + home, + activityCount: 1, + privateValues: [canary, directory], + }), + "no registry or runtime home writes beyond failed activity" + ); process.stdout.write( `Relocated native endpoint planning: ${checks} checks passed; no hooks/DNS/trust/runtime effects\n` ); diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts index 04e189bc0..810c3c8ef 100644 --- a/scripts/check-native-env-plan-cli.ts +++ b/scripts/check-native-env-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home-state.ts"; /** Real compiled CLI/sidecar, isolated HOME and Git worktrees, no source or toolchain on PATH. */ const root = resolve(import.meta.dir, ".."); @@ -249,6 +250,11 @@ try { await Bun.write(join(checkout, ".hack/hack.config.json"), "{}"); await refused([], "E_NATIVE_PROJECT_CONFLICT", checkout); await rm(join(checkout, ".hack/hack.config.json")); + require(await nativePlanHomeMatches({ + home, + activityCount: 0, + privateValues: [sentinel, directory], + }), "offline planning creates no home state"); const unsupported = await invoke( ["up", "web", "--detach", "--json"], checkout, @@ -276,10 +282,14 @@ try { detachedResult.ok === false && record(detachedResult.error).code === "E_STARTUP_INCOMPLETE", "admitted detached startup returns one JSON failure envelope without readiness"); - require((await readdir(home)).length === 0 && - !(await readdir(join(checkout, ".hack"))).some( - (name) => name === ".internal" || name === ".branch" - ), "no registry or generated runtime state"); + require(await nativePlanHomeMatches({ + home, + activityCount: 2, + privateValues: [sentinel, directory], + }), "only private failed lifecycle activity is recorded"); + require(!(await readdir(join(checkout, ".hack"))).some( + (name) => name === ".internal" || name === ".branch" + ), "no registry or generated runtime state"); process.stdout.write( "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" ); diff --git a/scripts/check-native-host-plan-cli.ts b/scripts/check-native-host-plan-cli.ts index de6dd3033..b841a778c 100644 --- a/scripts/check-native-host-plan-cli.ts +++ b/scripts/check-native-host-plan-cli.ts @@ -3,6 +3,7 @@ import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home-state.ts"; /** Actual relocated CLI and sidecar; no Bun/Rust on PATH and no host effects. */ const root = resolve(import.meta.dir, ".."); @@ -277,6 +278,14 @@ try { "local settings cannot inject host commands" ); await rm(join(checkout, ".hack/hack.local.json")); + assert( + await nativePlanHomeMatches({ + home, + activityCount: 0, + privateValues: [canary, directory], + }), + "offline planning creates no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -288,7 +297,11 @@ try { ); assert( !(await Bun.file(marker).exists()) && - (await readdir(home)).length === 0 && + (await nativePlanHomeMatches({ + home, + activityCount: 1, + privateValues: [canary, directory], + })) && !(await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ), diff --git a/scripts/check-native-process-plan-cli.ts b/scripts/check-native-process-plan-cli.ts index a9e2d0c8b..ac0e08fca 100644 --- a/scripts/check-native-process-plan-cli.ts +++ b/scripts/check-native-process-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home-state.ts"; /** Qualify pure process planning through a relocated bundle and real Git worktree. */ const root = resolve(import.meta.dir, ".."); @@ -262,6 +263,14 @@ try { ], checkout ); + assert( + await nativePlanHomeMatches({ + home, + activityCount: 0, + privateValues: [canary, directory], + }), + "offline planning creates no unexpected home state" + ); const runtime = await invoke( ["up", "web", "--detach"], checkout, @@ -289,7 +298,11 @@ try { ); } assert( - (await readdir(join(home, ".hack")).catch(() => [])).length === 0, + await nativePlanHomeMatches({ + home, + activityCount: 1, + privateValues: [canary, directory], + }), "planning creates no registry or runtime home state" ); process.stdout.write( diff --git a/scripts/check-native-routing-plan-cli.ts b/scripts/check-native-routing-plan-cli.ts index f85076287..d2b079ec0 100644 --- a/scripts/check-native-routing-plan-cli.ts +++ b/scripts/check-native-routing-plan-cli.ts @@ -10,6 +10,7 @@ import { import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { isRecord } from "../src/lib/guards.ts"; +import { nativePlanHomeMatches } from "./lib/native-plan-home-state.ts"; /** Exercise the relocated executable/sidecar with isolated policy and real linked Git worktrees. */ const root = resolve(import.meta.dir, ".."); @@ -378,6 +379,15 @@ try { "local route injection refused" ); await rm(join(checkout, ".hack/hack.local.json")); + assert( + await nativePlanHomeMatches({ + home, + activityCount: 0, + existingConfig: true, + privateValues: [canary, directory], + }), + "offline planning creates no unexpected home state" + ); const runtime = await invoke(["up", "web", "--detach", "--json"], checkout, { HACK_RUNTIME_BACKEND: "native", HACK_NATIVE_BINARY: join(bundle, "absent-runtime"), @@ -393,7 +403,13 @@ try { (await readdir(join(checkout, ".hack"))).some( (name) => name === ".internal" || name === ".branch" ) - ) && (await readdir(join(home, ".hack"))).join() === "hack.config.json", + ) && + (await nativePlanHomeMatches({ + home, + activityCount: 1, + existingConfig: true, + privateValues: [canary, directory], + })), "no hook, registry, route publication or generated-state effects" ); process.stdout.write( diff --git a/scripts/lib/native-plan-home-state.ts b/scripts/lib/native-plan-home-state.ts new file mode 100644 index 000000000..e34ef96be --- /dev/null +++ b/scripts/lib/native-plan-home-state.ts @@ -0,0 +1,63 @@ +import { lstat, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { isRecord } from "../../src/lib/guards.ts"; + +/** Exact offline fixture state: planning writes nothing; failed up may add private activity only. */ +export async function nativePlanHomeMatches(opts: { + readonly home: string; + readonly activityCount: number; + readonly existingConfig?: boolean; + readonly privateValues: readonly string[]; +}): Promise { + try { + const entries = await readdir(opts.home); + if (opts.activityCount === 0 && !opts.existingConfig) { + return entries.length === 0; + } + if (entries.length !== 1 || entries[0] !== ".hack") { + return false; + } + const hackHome = join(opts.home, ".hack"); + const directory = await lstat(hackHome); + if (!directory.isDirectory() || (directory.mode & 0o022) !== 0) { + return false; + } + const expected = [ + ...(opts.existingConfig ? ["hack.config.json"] : []), + ...(opts.activityCount > 0 ? ["activity.jsonl"] : []), + ].sort(); + const actual = (await readdir(hackHome)).sort(); + if (actual.join("\n") !== expected.join("\n")) { + return false; + } + if (opts.activityCount === 0) { + return true; + } + const path = join(hackHome, "activity.jsonl"); + const info = await lstat(path); + if (!info.isFile() || (info.mode & 0o777) !== 0o600 || info.size > 65_536) { + return false; + } + const text = await Bun.file(path).text(); + if ( + opts.privateValues.some( + (value) => value.length > 0 && text.includes(value) + ) + ) { + return false; + } + const records: unknown[] = text + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + return ( + records.length === opts.activityCount && + records.every( + (entry) => + isRecord(entry) && entry.action === "up" && entry.outcome === "failed" + ) + ); + } catch { + return false; + } +} diff --git a/tests/native-plan-home-state.test.ts b/tests/native-plan-home-state.test.ts new file mode 100644 index 000000000..af5514d11 --- /dev/null +++ b/tests/native-plan-home-state.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { nativePlanHomeMatches } from "../scripts/lib/native-plan-home-state.ts"; + +let home = ""; +let hackHome = ""; +let activity = ""; +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), "hack-plan-home-")); + hackHome = join(home, ".hack"); + activity = join(hackHome, "activity.jsonl"); +}); +afterEach(async () => { + await rm(home, { recursive: true, force: true }); +}); +function matches(activityCount: number, existingConfig = false) { + return nativePlanHomeMatches({ + home, + activityCount, + existingConfig, + privateValues: ["synthetic-private-value", home], + }); +} +async function install() { + await mkdir(hackHome, { mode: 0o700 }); + await writeFile(activity, '{"action":"up","outcome":"failed"}\n', { + mode: 0o600, + }); +} + +test("offline planning is empty and lifecycle activity requires the exact count and outcome", async () => { + expect(await matches(0)).toBe(true); + await install(); + expect(await matches(0)).toBe(false); + expect(await matches(1)).toBe(true); + expect(await matches(2)).toBe(false); + for (const text of [ + '{"action":"up","outcome":"ok"}', + '{"action":"down","outcome":"failed"}', + "invalid", + '{"action":"up","outcome":"failed","detail":"synthetic-private-value"}', + JSON.stringify({ action: "up", outcome: "failed", detail: home }), + ]) { + await writeFile(activity, text); + expect(await matches(1)).toBe(false); + } +}); + +test("extra registry, native, or lock state never hides behind activity", async () => { + await install(); + for (const name of ["projects.json", "native", "activity.jsonl.lock"]) { + const path = join(hackHome, name); + await writeFile(path, "{}"); + expect(await matches(1)).toBe(false); + await rm(path); + } + await writeFile(join(home, "native"), "{}"); + expect(await matches(1)).toBe(false); +}); + +test("activity must be a private regular file under a protected directory", async () => { + await install(); + await chmod(activity, 0o644); + expect(await matches(1)).toBe(false); + await chmod(activity, 0o600); + await chmod(hackHome, 0o777); + expect(await matches(1)).toBe(false); + await chmod(hackHome, 0o700); + await rm(activity); + const target = join(hackHome, "hack.config.json"); + await writeFile(target, '{"action":"up","outcome":"failed"}', { + mode: 0o600, + }); + await symlink(target, activity); + expect(await matches(1, true)).toBe(false); +}); + +test("routing fixtures may retain only their explicitly supplied config", async () => { + await mkdir(hackHome, { mode: 0o700 }); + await writeFile(join(hackHome, "hack.config.json"), "{}"); + expect(await matches(0)).toBe(false); + expect(await matches(0, true)).toBe(true); + await writeFile(activity, '{"action":"up","outcome":"failed"}', { + mode: 0o600, + }); + expect(await matches(1, true)).toBe(true); + await writeFile(join(hackHome, "projects.json"), "{}"); + expect(await matches(1, true)).toBe(false); +}); From afe115f55d97198997feead00b094ea5a4e5ee83 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:43:38 +0000 Subject: [PATCH 24/44] test(autostart): canonicalize ACL fixture paths across platforms --- tests/autostart-executable-trust.test.ts | 31 +++++++++++++++++++++++- tests/autostart-path-acl.test.ts | 4 +-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/tests/autostart-executable-trust.test.ts b/tests/autostart-executable-trust.test.ts index b37489305..22c48f527 100644 --- a/tests/autostart-executable-trust.test.ts +++ b/tests/autostart-executable-trust.test.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, readFile, + realpath, rm, symlink, writeFile, @@ -16,7 +17,9 @@ let root = ""; let bin = ""; let program = ""; beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), "hack-executable-trust-")); + root = await realpath( + await mkdtemp(join(tmpdir(), "hack-executable-trust-")) + ); bin = join(root, "bin"); program = join(bin, "docker"); await mkdir(bin, { mode: 0o700 }); @@ -158,6 +161,32 @@ for (const stdout of [ }); } +test("Darwin ACL inventories retain an ancestor symlink and its physical executable chain", async () => { + const physical = join(root, "physical"); + const physicalBin = join(physical, "bin"); + const alias = join(root, "alias"); + await mkdir(physicalBin, { recursive: true, mode: 0o700 }); + const helper = join(physicalBin, "docker"); + await writeFile(helper, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + await symlink(physical, alias); + const inspected = new Set(); + await assertAutostartExecutablePathTrusted( + { path: join(alias, "bin") }, + { + platform: "darwin", + inspect: async (paths) => { + for (const path of paths) { + inspected.add(path); + } + return { exitCode: 0, stdout: "" }; + }, + } + ); + for (const path of [root, alias, physical, physicalBin, helper]) { + expect(inspected.has(path)).toBe(true); + } +}); + test("Darwin ACL probe failures refuse with closed diagnostics", async () => { for (const exitCode of [1, 124, 125]) { await expect( diff --git a/tests/autostart-path-acl.test.ts b/tests/autostart-path-acl.test.ts index 14c4540d6..4473e0d8c 100644 --- a/tests/autostart-path-acl.test.ts +++ b/tests/autostart-path-acl.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { rootCertificates } from "node:tls"; @@ -15,7 +15,7 @@ import { readProjectsRegistry } from "../src/lib/projects-registry.ts"; let root = ""; let projectDir = ""; beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), "hack-acl-boundary-")); + root = await realpath(await mkdtemp(join(tmpdir(), "hack-acl-boundary-"))); projectDir = join(root, ".hack"); await mkdir(projectDir, { mode: 0o700 }); await writeFile(join(projectDir, "hack.config.json"), '{"name":"fixture"}', { From 121a3b3067eb3b5328ffde022ff1fe2947ab99b1 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 08:51:42 +0000 Subject: [PATCH 25/44] docs(autostart): explain routing asset refresh after upgrades --- docs/always-on-projects.md | 4 ++++ docs/versions/v5/getting-started.md | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index aaed5bb81..422614f3f 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -167,6 +167,10 @@ directories, and regenerate the installed files with `hack global install` before another login pass. Unexpected files in the mounted `schemas` or Grafana `dashboards` directories must be reviewed and moved outside those directories; reinstallation alone does not remove extra files. Ordinary manual routing commands keep their behavior. +If an upgrade changes the installed templates or schemas, run `hack global install` +manually before relying on the next login pass. An earlier routing refusal can +consume this boot's project attempts; use manual `hack up` to recover those +projects rather than expecting another automatic attempt in the same boot. Adoption requires a running container. An exited-zero container is treated as a completed job only when its service is a dependency target explicitly declared with `condition: service_completed_successfully`; other stopped services require diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 25453cdcb..4ac4a2c7b 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -78,6 +78,8 @@ Retained projects created by `hack config adopt` also require manual starts. Host-file/source-code bind mounts also require manual starts. Verified default engine volumes and Hack's authenticated read-only TLS mounts remain supported. Interrupting `hack autostart run` cancels the pass and waits for startup cleanup. +After upgrades that change global templates, run `hack global install` manually +before relying on login routing. Expect the declared services in `ps` and the app's local URL from `open`. If a project has several URLs, choose the app's route. A worker-only project may From 51a813a0f2e3215311e321adec00bfc8219b1561 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:14:46 +0000 Subject: [PATCH 26/44] fix(autostart): keep logging startup manual at login --- docs/always-on-projects.md | 7 +++++-- docs/versions/v5/getting-started.md | 4 ++++ src/commands/global-autostart.ts | 8 ++++++++ src/commands/global.ts | 20 +++++++++++++------- tests/autostart-routing-assets.test.ts | 25 ++++++++++++++++++++++++- 5 files changed, 54 insertions(+), 10 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 422614f3f..46ee06cd6 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -157,8 +157,11 @@ ownership, file type and every directory component, refuses symlinks and unsafe parent directories, and compares bounded reads with those templates. All files must pass before matching non-secret bytes are republished on fresh `0644` inodes, so container users can read them while other host users cannot write them. Routing -and logging containers are recreated to bind those new files; an old open writer -or old file mount cannot modify the new assets. Compose `.env` imports and the +containers are recreated to bind those new files; an old open writer or old file +mount cannot modify their new assets. Login starts only Caddy/CoreDNS, not the +logging stack (Grafana, Loki and Alloy). Start logging manually with `hack global up`; +its default Grafana credentials must be changed before exposing it to a network. +Already-running logging containers are left running and retain their existing mounts. Compose `.env` imports and the unsupported remote-route bridge are disabled for this automatic path. Customized, outdated, missing or unsafe assets cause `routing_failed` and no project starts. This path does not install or migrate assets. Review the paths, diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index c08fd76dd..b5823df18 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -130,3 +130,7 @@ This stops the project. It does not delete its named volumes or stop shared routing services. See [Run and inspect](guides/run-project.md) for service selection, log history and restarts. When this Compose run works, try native only on a [disposable project](native/start.md). + +Always-on login restores Caddy/CoreDNS routing only. Logging (Grafana, Loki and +Alloy) starts through manual `hack global up`; login leaves existing logging +containers running. Change default Grafana credentials before network exposure. diff --git a/src/commands/global-autostart.ts b/src/commands/global-autostart.ts index 4f0c63326..f8df4b3da 100644 --- a/src/commands/global-autostart.ts +++ b/src/commands/global-autostart.ts @@ -68,3 +68,11 @@ export async function reconcileGlobalRoutingBridge(opts: { }); } } + +/** Login restores routing only; logging can expose retained logs through Grafana. */ +export function globalLoggingUpArguments(opts: { + readonly composeFile: string; + readonly login: boolean; +}): readonly string[] | null { + return opts.login ? null : globalRoutingUpArguments(opts); +} diff --git a/src/commands/global.ts b/src/commands/global.ts index 30d816a53..b9f6e8b88 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -180,6 +180,7 @@ import { type TrustPlanDeps, } from "./global-admin-plan.ts"; import { + globalLoggingUpArguments, globalRoutingUpArguments, reconcileGlobalRoutingBridge, secureGlobalLoginRouting, @@ -1944,13 +1945,18 @@ export async function globalUp(): Promise { } await reconcileGlobalRoutingBridge({ enabled: !login }); - logger.step({ message: "Starting logging stack…" }); - const logExit = await run( - globalRoutingUpArguments({ composeFile: paths.loggingCompose, login }), - { cwd: dirname(paths.loggingCompose) } - ); - if (logExit !== 0) { - return logExit; + const loggingArguments = globalLoggingUpArguments({ + composeFile: paths.loggingCompose, + login, + }); + if (loggingArguments) { + logger.step({ message: "Starting logging stack…" }); + const logExit = await run(loggingArguments, { + cwd: dirname(paths.loggingCompose), + }); + if (logExit !== 0) { + return logExit; + } } logger.success({ message: "Global infra is up" }); diff --git a/tests/autostart-routing-assets.test.ts b/tests/autostart-routing-assets.test.ts index 945855e13..e026b5ef9 100644 --- a/tests/autostart-routing-assets.test.ts +++ b/tests/autostart-routing-assets.test.ts @@ -13,7 +13,10 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { globalRoutingUpArguments } from "../src/commands/global-autostart.ts"; +import { + globalLoggingUpArguments, + globalRoutingUpArguments, +} from "../src/commands/global-autostart.ts"; import { GLOBAL_ALLOY_FILENAME, GLOBAL_BRANCHES_SCHEMA_FILENAME, @@ -411,3 +414,23 @@ test("missing assets are never created and unknown mounted content is refused", await expect(secure()).rejects.toMatchObject({ code: "E_PERMISSION" }); expect(await Bun.file(root).exists()).toBe(false); }); + +test("login plans routing without starting default-credential logging services", () => { + const composeFile = "/example/logging/compose.yml"; + expect(globalLoggingUpArguments({ composeFile, login: true })).toBeNull(); + expect( + globalRoutingUpArguments({ + composeFile: "/example/caddy/compose.yml", + login: true, + }) + ).toContain("--force-recreate"); + expect(globalLoggingUpArguments({ composeFile, login: false })).toEqual([ + "docker", + "compose", + "-f", + composeFile, + "up", + "-d", + "--remove-orphans", + ]); +}); From 6cf4fb670edd087b5a753c6b601d69076d924c62 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:32:44 +0000 Subject: [PATCH 27/44] fix(autostart): refuse SSH transport and host recovery at login --- docs/always-on-projects.md | 8 +++ docs/versions/v5/getting-started.md | 4 ++ src/commands/global-autostart.ts | 18 +++++++ src/commands/global.ts | 12 +++-- src/daemon/autostart.ts | 2 +- src/lib/autostart-docker-trust.ts | 43 +++++++++++++++ tests/autostart-docker-trust.test.ts | 74 ++++++++++++++++++++++++++ tests/autostart-routing-assets.test.ts | 14 +++++ tests/daemon-autostart.test.ts | 24 +++++++++ 9 files changed, 195 insertions(+), 4 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 46ee06cd6..fa75db121 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -391,3 +391,11 @@ With `--overwrite` that can also restore old pauses and boot claims; it does not merge newer machine intent. An overwrite rollback can undo a global pause as well. Review and reapply the restored machine intent before allowing another login pass. Upgrade-aware state reconciliation is part of HACK-2216. + +Login refuses SSH Docker contexts and direct SSH Docker hosts: SSH configuration +and its includes can execute host commands outside the Docker trust audit. Use a +local engine or start manually. Login also skips macOS dnsmasq startup and recovery, +including the passwordless DNS helper. Prepare DNS manually before relying on +login routing; manual `hack global up` retains its existing DNS behavior. +If Docker stops between readiness and routing startup, login fails without +launching a backend VM or app. Start the engine manually before retrying. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index b5823df18..343d97e9c 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -134,3 +134,7 @@ log history and restarts. When this Compose run works, try native only on a Always-on login restores Caddy/CoreDNS routing only. Logging (Grafana, Loki and Alloy) starts through manual `hack global up`; login leaves existing logging containers running. Change default Grafana credentials before network exposure. + +Login requires a non-SSH Docker transport and already-prepared DNS. It skips +macOS dnsmasq startup and passwordless DNS recovery; use manual `hack global up` +when DNS needs starting or recovery. diff --git a/src/commands/global-autostart.ts b/src/commands/global-autostart.ts index f8df4b3da..c37239099 100644 --- a/src/commands/global-autostart.ts +++ b/src/commands/global-autostart.ts @@ -76,3 +76,21 @@ export function globalLoggingUpArguments(opts: { }): readonly string[] | null { return opts.login ? null : globalRoutingUpArguments(opts); } + +/** Login must not invoke the absolute passwordless DNS recovery helper. */ +export function globalDnsRecoveryAllowed(opts: { + readonly login: boolean; +}): boolean { + return !opts.login; +} + +/** A lost engine at login must fail before backend discovery or VM/app startup. */ +export function assertGlobalBackendStartAllowed(opts: { + readonly login: boolean; +}): void { + if (opts.login) { + throw new Error( + "Docker became unavailable during login; start the engine manually and retry." + ); + } +} diff --git a/src/commands/global.ts b/src/commands/global.ts index b9f6e8b88..6e6084113 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -180,6 +180,8 @@ import { type TrustPlanDeps, } from "./global-admin-plan.ts"; import { + assertGlobalBackendStartAllowed, + globalDnsRecoveryAllowed, globalLoggingUpArguments, globalRoutingUpArguments, reconcileGlobalRoutingBridge, @@ -867,12 +869,16 @@ function getGlobalPaths() { }; } -async function ensureDockerRunning(): Promise { +async function ensureDockerRunning(opts?: { + readonly login: boolean; +}): Promise { const res = await exec(["docker", "info"], { stdin: "ignore" }); if (res.exitCode === 0) { return; } + assertGlobalBackendStartAllowed({ login: opts?.login === true }); + const backend = await detectDockerBackend(); if (!backend) { throw new Error( @@ -1847,8 +1853,8 @@ export async function globalUp(): Promise { const login = process.env.HACK_AUTOSTART_LOGIN === "1"; await secureGlobalLoginRouting({ root: paths.root, login }); - await ensureDockerRunning(); - if (isMac()) { + await ensureDockerRunning({ login }); + if (isMac() && globalDnsRecoveryAllowed({ login })) { await ensureMacDnsmasqRunning(); } if (!(await pathExists(paths.caddyCompose))) { diff --git a/src/daemon/autostart.ts b/src/daemon/autostart.ts index c8245e7bf..4e7468072 100644 --- a/src/daemon/autostart.ts +++ b/src/daemon/autostart.ts @@ -657,7 +657,7 @@ export function createAutostartProductionDependencies( return "executable_path_untrusted"; } try { - assertAutostartDockerConfigTrusted({ home: runtime.home }); + assertAutostartDockerConfigTrusted({ home: runtime.home, env }); } catch { return "docker_config_untrusted"; } diff --git a/src/lib/autostart-docker-trust.ts b/src/lib/autostart-docker-trust.ts index a6497cc18..b190bf3b4 100644 --- a/src/lib/autostart-docker-trust.ts +++ b/src/lib/autostart-docker-trust.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { join } from "node:path"; import { assertAutostartPathAclsTrustedSync } from "./autostart-path-acl.ts"; @@ -5,9 +6,12 @@ import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; import { walkReplaceableChain } from "./path-trust.ts"; +const LOGIN_DOCKER_TRANSPORT = /^(unix|tcp|npipe):\/\//; + /** Authenticate Docker's engine-selection inputs without exposing credentials. */ export function assertAutostartDockerConfigTrusted(opts: { readonly home: string; + readonly env?: Readonly>; readonly systemPluginDirectories?: readonly string[]; }): void { try { @@ -66,6 +70,8 @@ export function assertAutostartDockerConfigTrusted(opts: { ]) { visit(directory, 0); } + const env = opts.env ?? process.env; + let currentContext = "default"; const configPath = join(root, "config.json"); assertAutostartPathAclsTrustedSync(checked); const configTrust = walkReplaceableChain({ @@ -83,11 +89,41 @@ export function assertAutostartDockerConfigTrusted(opts: { if (!isRecord(config)) { throw new Error("Invalid Docker configuration"); } + if (config.currentContext !== undefined) { + if (typeof config.currentContext !== "string") { + throw new Error("Invalid context selection"); + } + currentContext = config.currentContext || "default"; + } if (config.cliPluginsExtraDirs !== undefined) { // Custom plugin discovery is not part of the fixed login toolchain. throw new Error("Custom plugin search path requires manual startup"); } } + const context = env.DOCKER_CONTEXT?.trim() || currentContext; + const host = env.DOCKER_HOST?.trim(); + if (context !== "default") { + const digest = createHash("sha256").update(context).digest("hex"); + const metadataPath = join(root, "contexts/meta", digest, "meta.json"); + if (statSync(metadataPath).size > 1024 * 1024) { + throw new Error("Context metadata exceeds bound"); + } + const metadata: unknown = JSON.parse(readFileSync(metadataPath, "utf8")); + if ( + !( + isRecord(metadata) && + isRecord(metadata.Endpoints) && + isRecord(metadata.Endpoints.docker) + ) + ) { + throw new Error("Invalid Docker endpoint"); + } + assertLoginDockerTransport(metadata.Endpoints.docker.Host); + } + // A direct host may select the transport when the default context is used. + if (host) { + assertLoginDockerTransport(host); + } } catch { throw new HackCliError({ code: "E_PERMISSION", @@ -97,3 +133,10 @@ export function assertAutostartDockerConfigTrusted(opts: { }); } } + +/** SSH loads executable user configuration outside the authenticated Docker tree. */ +function assertLoginDockerTransport(host: unknown): void { + if (typeof host !== "string" || !LOGIN_DOCKER_TRANSPORT.test(host)) { + throw new Error("Docker transport requires manual startup"); + } +} diff --git a/tests/autostart-docker-trust.test.ts b/tests/autostart-docker-trust.test.ts index 6ced755b9..234f8734b 100644 --- a/tests/autostart-docker-trust.test.ts +++ b/tests/autostart-docker-trust.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { chmod, mkdir, @@ -64,3 +65,76 @@ test("writable Docker config or context inputs refuse before contacting an engin await symlink(target, join(home, ".docker/config.json")); expect(() => assertAutostartDockerConfigTrusted({ home })).toThrow(); }); + +test("selected SSH contexts and direct SSH hosts refuse before contacting an engine", async () => { + const name = "remote-fixture"; + const context = join( + home, + ".docker/contexts/meta", + createHash("sha256").update(name).digest("hex") + ); + await mkdir(context, { recursive: true, mode: 0o700 }); + const metadata = join(context, "meta.json"); + await writeFile( + join(home, ".docker/config.json"), + JSON.stringify({ currentContext: name }), + { mode: 0o600 } + ); + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: {}, + systemPluginDirectories: [], + }) + ).toThrow(); + await writeFile(metadata, "{}", { mode: 0o600 }); + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: {}, + systemPluginDirectories: [], + }) + ).toThrow(); + for (const host of [ + "ssh://fixture", + "unknown://fixture", + "unix:///fixture/docker.sock", + "tcp://127.0.0.1:2376", + ]) { + await writeFile( + metadata, + JSON.stringify({ Name: name, Endpoints: { docker: { Host: host } } }), + { mode: 0o600 } + ); + const check = () => + assertAutostartDockerConfigTrusted({ + home, + env: {}, + systemPluginDirectories: [], + }); + if (host.startsWith("ssh:") || host.startsWith("unknown:")) { + expect(check).toThrow(); + } else { + expect(check).not.toThrow(); + } + } + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: { DOCKER_CONTEXT: "default", DOCKER_HOST: "ssh://fixture" }, + systemPluginDirectories: [], + }) + ).toThrow(); + await writeFile( + metadata, + JSON.stringify({ Endpoints: { docker: { Host: "ssh://fixture" } } }), + { mode: 0o600 } + ); + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: { DOCKER_CONTEXT: name }, + systemPluginDirectories: [], + }) + ).toThrow(); +}); diff --git a/tests/autostart-routing-assets.test.ts b/tests/autostart-routing-assets.test.ts index e026b5ef9..6c6aa7884 100644 --- a/tests/autostart-routing-assets.test.ts +++ b/tests/autostart-routing-assets.test.ts @@ -14,6 +14,8 @@ import { import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { + assertGlobalBackendStartAllowed, + globalDnsRecoveryAllowed, globalLoggingUpArguments, globalRoutingUpArguments, } from "../src/commands/global-autostart.ts"; @@ -434,3 +436,15 @@ test("login plans routing without starting default-credential logging services", "--remove-orphans", ]); }); + +test("login refuses passwordless DNS recovery while manual starts retain it", () => { + expect(globalDnsRecoveryAllowed({ login: true })).toBe(false); + expect(globalDnsRecoveryAllowed({ login: false })).toBe(true); +}); + +test("an unavailable engine at login refuses backend startup while manual startup remains allowed", () => { + expect(() => assertGlobalBackendStartAllowed({ login: true })).toThrow( + "start the engine manually" + ); + expect(() => assertGlobalBackendStartAllowed({ login: false })).not.toThrow(); +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index 26bab0934..55493389b 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -1250,6 +1251,29 @@ test("unsafe Docker context selection refuses before engine access without consu ?.lastAttemptBootId ).toBeNull(); await chmod(config, 0o600); + const context = join( + root, + ".docker/contexts/meta", + createHash("sha256").update("untrusted").digest("hex") + ); + await mkdir(context, { recursive: true, mode: 0o700 }); + const metadata = join(context, "meta.json"); + await writeFile( + metadata, + JSON.stringify({ Endpoints: { docker: { Host: "ssh://fixture" } } }), + { mode: 0o600 } + ); + await run(); + expect(events).toEqual([]); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ?.lastAttemptBootId + ).toBeNull(); + await writeFile( + metadata, + JSON.stringify({ Endpoints: { docker: { Host: "unix:///fixture.sock" } } }), + { mode: 0o600 } + ); await run(); expect(starts.map((start) => start.id)).toEqual(["alpha"]); }); From bdf37b2e18065a3fb7a677d008f8075fbb8579c7 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 09:59:18 +0000 Subject: [PATCH 28/44] fix(autostart): preserve daemon service helper search paths --- docs/always-on-projects.md | 5 +++++ docs/versions/v5/getting-started.md | 2 ++ src/daemon/launchd.ts | 2 +- src/daemon/systemd.ts | 2 -- tests/daemon-systemd.test.ts | 5 +---- tests/launchd.test.ts | 2 +- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index fa75db121..13ee1e4f6 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -399,3 +399,8 @@ including the passwordless DNS helper. Prepare DNS manually before relying on login routing; manual `hack global up` retains its existing DNS behavior. If Docker stops between readiness and routing startup, login fails without launching a backend VM or app. Start the engine manually before retrying. + +The service daemon keeps its existing helper PATH; this feature does not add +`~/.docker/bin` to daemon cache or watcher lookups. Automatic-start children may +use that directory only after their separate strict executable audit. Broader +hardening of existing daemon observation helpers is outside this feature. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 343d97e9c..01599bfe9 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -138,3 +138,5 @@ containers running. Change default Grafana credentials before network exposure. Login requires a non-SSH Docker transport and already-prepared DNS. It skips macOS dnsmasq startup and passwordless DNS recovery; use manual `hack global up` when DNS needs starting or recovery. +The daemon service PATH is unchanged; only automatic-start children use the +separately audited Docker user-bin path. diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 3ea758f95..b514a7a2f 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -200,7 +200,7 @@ export function renderLaunchdPlist(opts: LaunchdPlistOptions): string { " HOME", ` ${opts.home}`, " PATH", - ` /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${opts.home}/.bun/bin:${opts.home}/.hack/bin:${opts.home}/.docker/bin`, + ` /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${opts.home}/.bun/bin:${opts.home}/.hack/bin`, " ", ]; diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index a33d60bd3..eacbdeb3f 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -508,7 +508,6 @@ export function renderSystemdUnit(opts: { "%h/.local/bin", "%h/.bun/bin", "%h/.hack/bin", - "%h/.docker/bin", ].join(":"); return [ "# Managed by hack: written by `hack daemon install`, removed by `hack daemon uninstall`.", @@ -1984,7 +1983,6 @@ export function renderedServicePathDirectories(opts: { resolve(opts.home, ".local/bin"), resolve(opts.home, ".bun/bin"), resolve(opts.home, ".hack/bin"), - resolve(opts.home, ".docker/bin"), ]; } diff --git a/tests/daemon-systemd.test.ts b/tests/daemon-systemd.test.ts index 8b1d3d3ca..a33d4b20a 100644 --- a/tests/daemon-systemd.test.ts +++ b/tests/daemon-systemd.test.ts @@ -499,9 +499,7 @@ linuxTest( expect(text).toContain("Environment=HACK_NO_INTERACTIVE=1"); expect(text).toContain("Environment=HACK_DAEMON_LOGIN_SERVICE=1"); expect(text).toContain("KillMode=control-group"); - expect(text).toContain( - "%h/.local/bin:%h/.bun/bin:%h/.hack/bin:%h/.docker/bin" - ); + expect(text).toContain("%h/.local/bin:%h/.bun/bin:%h/.hack/bin"); expect( renderedServicePathDirectories({ hackBinPath: "/opt/hack", @@ -515,7 +513,6 @@ linuxTest( "/example/user/.local/bin", "/example/user/.bun/bin", "/example/user/.hack/bin", - "/example/user/.docker/bin", ]); expect(extractSystemdExecProgram({ unitText: text ?? "" })).toBe( "/opt/my tools%/hack" diff --git a/tests/launchd.test.ts b/tests/launchd.test.ts index 7df2cae70..05e5f7d82 100644 --- a/tests/launchd.test.ts +++ b/tests/launchd.test.ts @@ -95,7 +95,7 @@ describe("launchd plist generation", () => { expect(plist).toContain("/bin"); expect(plist).toContain(".bun/bin"); expect(plist).toContain(".hack/bin"); - expect(plist).toContain("/Users/testuser/.docker/bin"); + expect(plist).not.toContain("/Users/testuser/.docker/bin"); }); test("plist is valid XML structure", () => { From 80283fd888e0d0ad308e532d766ee04ab4ea02bc Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:11:00 +0000 Subject: [PATCH 29/44] fix(autostart): ignore inactive dotenv templates and backups --- docs/always-on-projects.md | 4 +++ docs/versions/v5/getting-started.md | 2 ++ src/lib/autostart-start-policy.ts | 7 ++++- tests/autostart-start-policy.test.ts | 39 ++++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 13ee1e4f6..8eb87ce12 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -404,3 +404,7 @@ The service daemon keeps its existing helper PATH; this feature does not add `~/.docker/bin` to daemon cache or watcher lookups. Automatic-start children may use that directory only after their separate strict executable audit. Broader hardening of existing daemon observation helpers is outside this feature. +Dotenv template and backup copies (`.env.example`, `.env.sample`, `.env.template`, +`.env.dist`, `.env.bak`, `.env.backup`, `.env.old`, `.env.orig`) in the checkout +root or project directory do not block admission. Active `.env` overlays still +require migration, and Compose `env_file` inputs still require a manual start. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index dced7d7b9..3651baf50 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -145,3 +145,5 @@ macOS dnsmasq startup and passwordless DNS recovery; use manual `hack global up` when DNS needs starting or recovery. The daemon service PATH is unchanged; only automatic-start children use the separately audited Docker user-bin path. +Dotenv template and backup copies do not block always-on admission; active dotenv +overlays still need migration, and Compose `env_file` inputs require manual starts. diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index 2fd71ddd1..30a51e058 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -24,6 +24,7 @@ import { type ProjectDirName, parseProjectConfigRecord, } from "./project.ts"; +import { isExcludedLegacyDotenvName } from "./project-env-config.ts"; export type AutostartStartPolicyReason = | "host_commands_require_manual_start" @@ -310,7 +311,11 @@ async function envPolicyReason( function isEnvironmentInput(name: string): boolean { return ( - (name.startsWith(".env") && name !== PROJECT_ENV_STATE_FILENAME) || + (name.startsWith(".env") && + name !== PROJECT_ENV_STATE_FILENAME && + !( + name.startsWith(".env.") && isExcludedLegacyDotenvName(name.slice(5)) + )) || name === PROJECT_ENV_CONTRACT_FILENAME || (name.startsWith(PROJECT_ENV_CONFIG_FILENAME_PREFIX) && name.endsWith(PROJECT_ENV_CONFIG_FILENAME_SUFFIX)) diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts index e459844e6..411d0c8f8 100644 --- a/tests/autostart-start-policy.test.ts +++ b/tests/autostart-start-policy.test.ts @@ -469,3 +469,42 @@ test("legacy dotenv and contracts preserve the fixed pending migration refusal w await rm(path); } }); + +test("dotenv templates and backups are not active inputs in either project directory", async () => { + for (const directory of [opts.repoRoot, opts.projectDir]) { + for (const suffix of [ + "example", + "sample", + "template", + "dist", + "bak", + "backup", + "old", + "orig", + ]) { + const path = join(directory, `.env.${suffix}`); + await writeFile(path, "EXAMPLE_VALUE=nonempty\n", { mode: 0o600 }); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); + expect(await readFile(path, "utf8")).toBe("EXAMPLE_VALUE=nonempty\n"); + } + for (const name of [ + ".env", + ".env.production", + ".env.local", + ".env.production.local", + ]) { + const path = join(directory, name); + await writeFile(path, "ACTIVE_VALUE=nonempty\n", { mode: 0o600 }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "pending_env_migration" + ); + await rm(path); + } + } + await compose({ + services: { web: { image: "example/web:1", env_file: [".env.example"] } }, + }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "compose_inputs_require_manual_start" + ); +}); From 421078176b5a03d2a2ab2c929afce3c07c1e1d55 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:31:30 +0000 Subject: [PATCH 30/44] fix(autostart): require trusted local Docker socket endpoints --- docs/always-on-projects.md | 12 ++++ docs/versions/v5/getting-started.md | 4 ++ src/lib/autostart-docker-trust.ts | 60 +++++++++++++++++--- tests/autostart-docker-trust.test.ts | 83 ++++++++++++++++++++++++---- tests/daemon-autostart.test.ts | 11 ++++ 5 files changed, 152 insertions(+), 18 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 8eb87ce12..e43bdf072 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -408,3 +408,15 @@ Dotenv template and backup copies (`.env.example`, `.env.sample`, `.env.template `.env.dist`, `.env.bak`, `.env.backup`, `.env.old`, `.env.orig`) in the checkout root or project directory do not block admission. Active `.env` overlays still require migration, and Compose `env_file` inputs still require a manual start. +TCP Docker endpoints also require manual starts, including loopback and TLS +contexts. Context ownership does not authenticate a TCP peer, and unattended +startup does not validate a mutual TLS contract. Login transport admission accepts +local Unix sockets only on supported hosts. +On the supported Linux/macOS hosts, login uses Unix sockets only. Socket paths, +symlinks and parent chains must be owned by the user or root; group/other-writable +parents, including sticky directories, are refused. Existing endpoints must be +sockets owned by the user or root. Missing endpoints are allowed only beneath a +trusted existing chain while the daemon waits for the engine. +The same check applies to Docker's implicit `/var/run/docker.sock` endpoint. +Under the strict policy, a group-writable system socket directory also refuses +login startup; use manual starts rather than changing system permissions. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 3651baf50..36f8167a7 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -147,3 +147,7 @@ The daemon service PATH is unchanged; only automatic-start children use the separately audited Docker user-bin path. Dotenv template and backup copies do not block always-on admission; active dotenv overlays still need migration, and Compose `env_file` inputs require manual starts. +TCP Docker contexts require manual starts too; login accepts local Unix socket +transports, without claiming TCP peer authentication. +On Linux/macOS, login also requires a Unix socket under trusted parents; shared +or sticky socket directories are refused before engine access. diff --git a/src/lib/autostart-docker-trust.ts b/src/lib/autostart-docker-trust.ts index b190bf3b4..86a4dc079 100644 --- a/src/lib/autostart-docker-trust.ts +++ b/src/lib/autostart-docker-trust.ts @@ -1,12 +1,18 @@ import { createHash } from "node:crypto"; -import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { + lstatSync, + readdirSync, + readFileSync, + realpathSync, + statSync, +} from "node:fs"; import { join } from "node:path"; import { assertAutostartPathAclsTrustedSync } from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; import { walkReplaceableChain } from "./path-trust.ts"; -const LOGIN_DOCKER_TRANSPORT = /^(unix|tcp|npipe):\/\//; +const LOGIN_DOCKER_TRANSPORT = /^unix:\/\//; /** Authenticate Docker's engine-selection inputs without exposing credentials. */ export function assertAutostartDockerConfigTrusted(opts: { @@ -118,11 +124,13 @@ export function assertAutostartDockerConfigTrusted(opts: { ) { throw new Error("Invalid Docker endpoint"); } - assertLoginDockerTransport(metadata.Endpoints.docker.Host); + assertLoginDockerTransport(metadata.Endpoints.docker.Host, uid); } - // A direct host may select the transport when the default context is used. - if (host) { - assertLoginDockerTransport(host); + // Docker uses this socket when no named context or direct host is selected. + if (context === "default") { + assertLoginDockerTransport(host || "unix:///var/run/docker.sock", uid); + } else if (host) { + assertLoginDockerTransport(host, uid); } } catch { throw new HackCliError({ @@ -134,9 +142,45 @@ export function assertAutostartDockerConfigTrusted(opts: { } } -/** SSH loads executable user configuration outside the authenticated Docker tree. */ -function assertLoginDockerTransport(host: unknown): void { +/** Network transports need peer authentication beyond filesystem/context ownership. */ +function assertLoginDockerTransport(host: unknown, uid: number): void { if (typeof host !== "string" || !LOGIN_DOCKER_TRANSPORT.test(host)) { throw new Error("Docker transport requires manual startup"); } + const path = host.slice("unix://".length); + const checked = new Set(); + let physical: string | null = null; + try { + physical = realpathSync(path); + const info = lstatSync(physical); + if (!info.isSocket() || (info.uid !== uid && info.uid !== 0)) { + throw new Error("Unsafe Docker socket"); + } + // Socket write permissions grant connection, not replacement authority. + checked.add(physical); + } catch (error: unknown) { + if (!(isRecord(error) && error.code === "ENOENT")) { + throw error; + } + } + const trust = walkReplaceableChain({ + path, + uid, + hackHome: null, + checked, + allowMissing: true, + }); + if ("error" in trust) { + throw new Error("Unsafe socket path"); + } + for (const entry of checked) { + if (entry === physical) { + continue; + } + const info = lstatSync(entry); + if (info.isDirectory() && (info.mode & 0o022) !== 0) { + throw new Error("Replaceable socket parent"); + } + } + assertAutostartPathAclsTrustedSync(checked); } diff --git a/tests/autostart-docker-trust.test.ts b/tests/autostart-docker-trust.test.ts index 234f8734b..352b12922 100644 --- a/tests/autostart-docker-trust.test.ts +++ b/tests/autostart-docker-trust.test.ts @@ -8,7 +8,8 @@ import { symlink, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { assertAutostartDockerConfigTrusted } from "../src/lib/autostart-docker-trust.ts"; @@ -66,7 +67,7 @@ test("writable Docker config or context inputs refuse before contacting an engin expect(() => assertAutostartDockerConfigTrusted({ home })).toThrow(); }); -test("selected SSH contexts and direct SSH hosts refuse before contacting an engine", async () => { +test("network contexts and direct hosts refuse before contacting an engine", async () => { const name = "remote-fixture"; const context = join( home, @@ -99,6 +100,7 @@ test("selected SSH contexts and direct SSH hosts refuse before contacting an eng "ssh://fixture", "unknown://fixture", "unix:///fixture/docker.sock", + "npipe:////./pipe/docker_engine", "tcp://127.0.0.1:2376", ]) { await writeFile( @@ -112,19 +114,26 @@ test("selected SSH contexts and direct SSH hosts refuse before contacting an eng env: {}, systemPluginDirectories: [], }); - if (host.startsWith("ssh:") || host.startsWith("unknown:")) { + if ( + host.startsWith("ssh:") || + host.startsWith("unknown:") || + host.startsWith("tcp:") || + host.startsWith("npipe:") + ) { expect(check).toThrow(); } else { expect(check).not.toThrow(); } } - expect(() => - assertAutostartDockerConfigTrusted({ - home, - env: { DOCKER_CONTEXT: "default", DOCKER_HOST: "ssh://fixture" }, - systemPluginDirectories: [], - }) - ).toThrow(); + for (const host of ["ssh://fixture", "tcp://127.0.0.1:2376"]) { + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: { DOCKER_CONTEXT: "default", DOCKER_HOST: host }, + systemPluginDirectories: [], + }) + ).toThrow(); + } await writeFile( metadata, JSON.stringify({ Endpoints: { docker: { Host: "ssh://fixture" } } }), @@ -138,3 +147,57 @@ test("selected SSH contexts and direct SSH hosts refuse before contacting an eng }) ).toThrow(); }); + +test("socket endpoints refuse shared sticky parents and regular files", () => { + for (const host of [ + "unix:///tmp/hack-unowned-socket-fixture/docker.sock", + "unix:///etc/passwd", + "npipe:////./pipe/docker_engine", + ]) { + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: { DOCKER_HOST: host }, + systemPluginDirectories: [], + }) + ).toThrow(); + } +}); + +test("owned socket and symlink pass; writable or sticky parents refuse", async () => { + const socketRoot = await mkdtemp(join(homedir(), ".hack-socket-trust-test-")); + const socket = join(socketRoot, "docker.sock"); + const alias = join(socketRoot, "engine.sock"); + const server = createServer(); + try { + await chmod(socketRoot, 0o700); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socket, resolve); + }); + await chmod(socket, 0o660); + await symlink(socket, alias); + for (const path of [socket, alias]) { + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: { DOCKER_HOST: `unix://${path}` }, + systemPluginDirectories: [], + }) + ).not.toThrow(); + } + for (const mode of [0o777, 0o1777]) { + await chmod(socketRoot, mode); + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: { DOCKER_HOST: `unix://${alias}` }, + systemPluginDirectories: [], + }) + ).toThrow(); + } + } finally { + await new Promise((resolve) => server.close(() => resolve())); + await rm(socketRoot, { recursive: true, force: true }); + } +}); diff --git a/tests/daemon-autostart.test.ts b/tests/daemon-autostart.test.ts index 55493389b..021bd1b4b 100644 --- a/tests/daemon-autostart.test.ts +++ b/tests/daemon-autostart.test.ts @@ -1269,6 +1269,17 @@ test("unsafe Docker context selection refuses before engine access without consu (await readProjectsRegistry({ registryPath })).projects[0]?.autostart ?.lastAttemptBootId ).toBeNull(); + await writeFile( + metadata, + JSON.stringify({ Endpoints: { docker: { Host: "tcp://127.0.0.1:2376" } } }), + { mode: 0o600 } + ); + await run(); + expect(events).toEqual([]); + expect( + (await readProjectsRegistry({ registryPath })).projects[0]?.autostart + ?.lastAttemptBootId + ).toBeNull(); await writeFile( metadata, JSON.stringify({ Endpoints: { docker: { Host: "unix:///fixture.sock" } } }), From 696912f820fc01cc4facabcf6a46004634ee9ea8 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:46:03 +0000 Subject: [PATCH 31/44] fix(autostart): reject writable sticky Docker plugin directories --- docs/always-on-projects.md | 3 +++ docs/versions/v5/getting-started.md | 2 ++ src/lib/autostart-docker-trust.ts | 5 +++++ tests/autostart-docker-trust.test.ts | 31 ++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index e43bdf072..f5ab270cf 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -420,3 +420,6 @@ trusted existing chain while the daemon waits for the engine. The same check applies to Docker's implicit `/var/run/docker.sock` endpoint. Under the strict policy, a group-writable system socket directory also refuses login startup; use manual starts rather than changing system permissions. +Docker plugin directories must also prevent other accounts from creating names: +sticky writable directories are refused even when empty. A missing plugin path +is refused if its nearest existing parent permits new entries by another account. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 36f8167a7..116898864 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -151,3 +151,5 @@ TCP Docker contexts require manual starts too; login accepts local Unix socket transports, without claiming TCP peer authentication. On Linux/macOS, login also requires a Unix socket under trusted parents; shared or sticky socket directories are refused before engine access. +Writable sticky Docker plugin directories also refuse login startup, including +empty directories where a plugin could be planted after an audit. diff --git a/src/lib/autostart-docker-trust.ts b/src/lib/autostart-docker-trust.ts index 86a4dc079..addb59313 100644 --- a/src/lib/autostart-docker-trust.ts +++ b/src/lib/autostart-docker-trust.ts @@ -44,6 +44,11 @@ export function assertAutostartDockerConfigTrusted(opts: { if ("error" in trust) { throw new Error("Unsafe selection input"); } + // Sticky directories protect existing names, not absent plugin/config names. + const existing = statSync(trust.existing); + if (existing.isDirectory() && (existing.mode & 0o022) !== 0) { + throw new Error("Selection directory permits new entries"); + } if (!trust.complete) { return; } diff --git a/tests/autostart-docker-trust.test.ts b/tests/autostart-docker-trust.test.ts index 352b12922..54aa8ad7a 100644 --- a/tests/autostart-docker-trust.test.ts +++ b/tests/autostart-docker-trust.test.ts @@ -201,3 +201,34 @@ test("owned socket and symlink pass; writable or sticky parents refuse", async ( await rm(socketRoot, { recursive: true, force: true }); } }); + +test("sticky user and system plugin directories cannot admit late plugin creation", async () => { + const root = join(home, ".docker"); + const plugins = join(root, "cli-plugins"); + const check = (systemPluginDirectories: readonly string[] = []) => + assertAutostartDockerConfigTrusted({ + home, + env: {}, + systemPluginDirectories, + }); + await mkdir(plugins, { mode: 0o700 }); + expect(() => check()).not.toThrow(); + for (const mode of [0o777, 0o1777]) { + await chmod(plugins, mode); + expect(() => check()).toThrow(); + } + await chmod(plugins, 0o700); + await rm(plugins, { recursive: true }); + await chmod(root, 0o1777); + expect(() => check()).toThrow(); + await chmod(root, 0o700); + const target = join(home, "system-plugins"); + await mkdir(target, { mode: 0o700 }); + await chmod(target, 0o1777); + expect(() => check([target])).toThrow(); + expect(() => check([join(target, "missing-plugins")])).toThrow(); + await symlink(target, plugins); + expect(() => check()).toThrow(); + await chmod(target, 0o700); + expect(() => check()).not.toThrow(); +}); From e8240291bad1ce8e7257b5d3171aa2ec872517c9 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:12:38 +0000 Subject: [PATCH 32/44] fix(autostart): match actual dotenv filenames for admission --- docs/always-on-projects.md | 2 ++ docs/versions/v5/getting-started.md | 1 + src/lib/autostart-start-policy.ts | 2 +- tests/autostart-start-policy.test.ts | 18 ++++++++++++++++++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index f5ab270cf..baf84c075 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -423,3 +423,5 @@ login startup; use manual starts rather than changing system permissions. Docker plugin directories must also prevent other accounts from creating names: sticky writable directories are refused even when empty. A missing plugin path is refused if its nearest existing parent permits new entries by another account. +Only `.env` and `.env.*` names are considered dotenv inputs; unrelated names such +as `.envrc` do not block admission and are not executed by login startup. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 116898864..3e76c0239 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -153,3 +153,4 @@ On Linux/macOS, login also requires a Unix socket under trusted parents; shared or sticky socket directories are refused before engine access. Writable sticky Docker plugin directories also refuse login startup, including empty directories where a plugin could be planted after an audit. +Direnv files such as `.envrc` are not dotenv overlays and do not block admission. diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index 30a51e058..3c851ce9d 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -311,7 +311,7 @@ async function envPolicyReason( function isEnvironmentInput(name: string): boolean { return ( - (name.startsWith(".env") && + ((name === ".env" || name.startsWith(".env.")) && name !== PROJECT_ENV_STATE_FILENAME && !( name.startsWith(".env.") && isExcludedLegacyDotenvName(name.slice(5)) diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts index 411d0c8f8..65cad8b4c 100644 --- a/tests/autostart-start-policy.test.ts +++ b/tests/autostart-start-policy.test.ts @@ -508,3 +508,21 @@ test("dotenv templates and backups are not active inputs in either project direc "compose_inputs_require_manual_start" ); }); + +test("direnv and other env-prefixed filenames are not dotenv inputs", async () => { + for (const directory of [opts.repoRoot, opts.projectDir]) { + for (const name of [".envrc", ".environment", ".env-not-an-overlay"]) { + const path = join(directory, name); + const contents = "echo fixture-shell-code\n"; + await writeFile(path, contents, { mode: 0o600 }); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); + expect(await readFile(path, "utf8")).toBe(contents); + } + const active = join(directory, ".env.production"); + await writeFile(active, "ACTIVE_VALUE=nonempty\n", { mode: 0o600 }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "pending_env_migration" + ); + await rm(active); + } +}); From e63ae8ba35912b6ecd7144038cc5982acf8664a1 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:26:40 +0000 Subject: [PATCH 33/44] fix(autostart): forbid routing image pulls during login --- docs/always-on-projects.md | 5 ++ docs/versions/v5/getting-started.md | 2 + src/backends/compose-unattended-preflight.ts | 34 +++++++++- src/commands/global-autostart.ts | 32 +++++++++- src/commands/global.ts | 5 ++ tests/autostart-routing-assets.test.ts | 4 ++ tests/global-routing-images.test.ts | 65 ++++++++++++++++++++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 tests/global-routing-images.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index baf84c075..b016e5519 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -425,3 +425,8 @@ sticky writable directories are refused even when empty. A missing plugin path is refused if its nearest existing parent permits new entries by another account. Only `.env` and `.env.*` names are considered dotenv inputs; unrelated names such as `.envrc` do not block admission and are not executed by login startup. +Login routing also requires all Caddy/CoreDNS images to be prepared locally. It +inspects image IDs before routing startup and runs Compose with `--no-build +--pull never`, so a missing image causes refusal instead of a registry fetch. +Manual `hack global up` prepares missing images. Local image contents remain +trusted engine state; this does not claim registry-tag or image-digest verification. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 3e76c0239..07293640c 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -154,3 +154,5 @@ or sticky socket directories are refused before engine access. Writable sticky Docker plugin directories also refuse login startup, including empty directories where a plugin could be planted after an audit. Direnv files such as `.envrc` are not dotenv overlays and do not block admission. +Prepare Caddy/CoreDNS images with manual `hack global up`: login routing never +pulls or builds images and refuses missing local images. diff --git a/src/backends/compose-unattended-preflight.ts b/src/backends/compose-unattended-preflight.ts index 3fe7d57f6..7302180ab 100644 --- a/src/backends/compose-unattended-preflight.ts +++ b/src/backends/compose-unattended-preflight.ts @@ -5,6 +5,7 @@ import type { RuntimeUpOptions } from "./runtime-backend.ts"; const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@+[\]-]{0,511}$/; const IMAGE_ID = /^sha256:[a-f0-9]{64}\s*$/; +const IMAGE_LIST_SEPARATOR = /\s+/; const VOLUME_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,255}$/; const VOLUME_FORMAT = '{"Name":{{json .Name}},"Driver":{{json .Driver}},"Options":{{json .Options}},"Labels":{{json .Labels}}}'; @@ -194,7 +195,10 @@ function modelSources( binds: [...binds.values()], }; } -function probeFor(opts: RuntimeUpOptions, timeoutMs: number) { +function probeFor( + opts: Pick, + timeoutMs: number +) { const deadline = Date.now() + Math.min(timeoutMs, 15_000); return async (args: readonly string[], maxStdoutBytes = 65_536) => { const remaining = deadline - Date.now(); @@ -254,6 +258,34 @@ async function imagePresent(image: string, probe: Probe): Promise { } return result.exitCode || 1; } + +/** Require prepared local images without permitting a registry pull or build. */ +export async function inspectUnattendedImages(opts: { + readonly composeArgs: readonly string[]; + readonly cwd: string; + readonly env?: Record; +}): Promise { + const probe = probeFor({ cwd: opts.cwd, env: opts.env }, 15_000); + const result = await probe([...opts.composeArgs, "config", "--images"]); + if (result.exitCode !== 0) { + return result.exitCode; + } + const images = [...new Set(result.stdout.trim().split(IMAGE_LIST_SEPARATOR))]; + if ( + images.length === 0 || + images.length > 256 || + images.some((image) => !IMAGE_REFERENCE.test(image)) + ) { + return 1; + } + for (const image of images) { + const code = await imagePresent(image, probe); + if (code !== 0) { + return code; + } + } + return 0; +} function resolvedModelHasBinds(value: unknown): boolean { if (!(isRecord(value) && isRecord(value.services))) { return false; diff --git a/src/commands/global-autostart.ts b/src/commands/global-autostart.ts index c37239099..c67b53b2b 100644 --- a/src/commands/global-autostart.ts +++ b/src/commands/global-autostart.ts @@ -1,4 +1,6 @@ import { homedir } from "node:os"; +import { dirname } from "node:path"; +import { inspectUnattendedImages } from "../backends/compose-unattended-preflight.ts"; import { assertAutostartDockerConfigTrusted } from "../lib/autostart-docker-trust.ts"; import { secureAutostartRoutingAssets } from "../lib/autostart-routing-assets.ts"; import { HackCliError } from "../lib/cli-result.ts"; @@ -45,11 +47,39 @@ export function globalRoutingUpArguments(opts: { opts.composeFile, "up", "-d", - ...(opts.login ? ["--force-recreate"] : []), + ...(opts.login + ? ["--force-recreate", "--no-build", "--pull", "never"] + : []), "--remove-orphans", ]; } +/** Routing has the same local-image prerequisite as unattended project starts. */ +export async function assertGlobalLoginRoutingImages(opts: { + readonly composeFile: string; + readonly login: boolean; +}): Promise { + if (!opts.login) { + return; + } + const code = await inspectUnattendedImages({ + composeArgs: [ + "docker", + "compose", + "--env-file", + "/dev/null", + "-f", + opts.composeFile, + ], + cwd: dirname(opts.composeFile), + }); + if (code !== 0) { + throw new Error( + "Automatic routing requires prepared local images; run hack global up manually." + ); + } +} + /** Unsupported remote bridges are never part of automatic local routing startup. */ export async function reconcileGlobalRoutingBridge(opts: { readonly enabled: boolean; diff --git a/src/commands/global.ts b/src/commands/global.ts index 6e6084113..fbf0115f7 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -181,6 +181,7 @@ import { } from "./global-admin-plan.ts"; import { assertGlobalBackendStartAllowed, + assertGlobalLoginRoutingImages, globalDnsRecoveryAllowed, globalLoggingUpArguments, globalRoutingUpArguments, @@ -1854,6 +1855,10 @@ export async function globalUp(): Promise { await secureGlobalLoginRouting({ root: paths.root, login }); await ensureDockerRunning({ login }); + await assertGlobalLoginRoutingImages({ + composeFile: paths.caddyCompose, + login, + }); if (isMac() && globalDnsRecoveryAllowed({ login })) { await ensureMacDnsmasqRunning(); } diff --git a/tests/autostart-routing-assets.test.ts b/tests/autostart-routing-assets.test.ts index 6c6aa7884..5d172619e 100644 --- a/tests/autostart-routing-assets.test.ts +++ b/tests/autostart-routing-assets.test.ts @@ -145,6 +145,10 @@ test("login routing recreates mounts and ignores env files while manual routing login: true, }); expect(login).toContain("--force-recreate"); + expect(login).toContain("--no-build"); + expect( + login.slice(login.indexOf("--pull"), login.indexOf("--pull") + 2) + ).toEqual(["--pull", "never"]); expect(login.slice(2, 4)).toEqual(["--env-file", "/dev/null"]); }); diff --git a/tests/global-routing-images.test.ts b/tests/global-routing-images.test.ts new file mode 100644 index 000000000..120934a21 --- /dev/null +++ b/tests/global-routing-images.test.ts @@ -0,0 +1,65 @@ +import { 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 { inspectUnattendedImages } from "../src/backends/compose-unattended-preflight.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +for (const scenario of ["present", "missing", "probe-error"] as const) { + test(`routing image preflight uses local inspection only: ${scenario}`, async () => { + const root = await mkdtemp(join(tmpdir(), "hack-routing-images-")); + try { + const bin = join(root, "bin"); + const calls = join(root, "calls"); + await mkdir(bin); + await writeFile( + join(bin, "docker"), + `#!${process.execPath} +const args=process.argv.slice(2); +await Bun.write(process.env.CALLS,(await Bun.file(process.env.CALLS).text().catch(()=>""))+JSON.stringify(args)+"\\n"); +if(args.includes("config") && args.includes("--images")) {console.log("example.invalid/routing:prepared");process.exit(0);} +if(args[0]==="image" && args[1]==="inspect") { + if(process.env.SCENARIO==="missing") {process.stderr.write("Error response from daemon: No such image: example.invalid/routing:prepared");process.exit(1);} + if(process.env.SCENARIO==="probe-error") process.exit(7); + console.log("sha256:"+"a".repeat(64));process.exit(0); +} +process.exit(99); +`, + { mode: 0o755 } + ); + const result = inspectUnattendedImages({ + composeArgs: [ + "docker", + "compose", + "--env-file", + "/dev/null", + "-f", + join(root, "compose.yml"), + ], + cwd: root, + env: privateCliEnv({ + root, + env: { PATH: bin, CALLS: calls, SCENARIO: scenario }, + }), + }); + if (scenario === "missing") { + await expect(result).rejects.toMatchObject({ + detail: { reason: "image_requires_manual_start" }, + }); + } else { + expect(await result).toBe(scenario === "present" ? 0 : 7); + } + const commands: string[][] = (await readFile(calls, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(commands).toHaveLength(2); + expect(commands[0]?.slice(-2)).toEqual(["config", "--images"]); + expect(commands[1]?.slice(0, 2)).toEqual(["image", "inspect"]); + expect(commands.flat()).not.toContain("pull"); + expect(commands.flat()).not.toContain("up"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} From 9f573c0eff0999159c7cfe1077df8622de3e5c8c Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:49:57 +0000 Subject: [PATCH 34/44] fix(autostart): keep global login progress out of gateway responses --- docs/always-on-projects.md | 3 +++ docs/versions/v5/getting-started.md | 1 + src/daemon/gateway-projects-payload.ts | 21 ++++++++++++++++ src/daemon/server.ts | 14 ++--------- tests/gateway-projects-payload.test.ts | 35 ++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 src/daemon/gateway-projects-payload.ts create mode 100644 tests/gateway-projects-payload.test.ts diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index b016e5519..4b4a3ff22 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -430,3 +430,6 @@ inspects image IDs before routing startup and runs Compose with `--no-build --pull never`, so a missing image causes refusal instead of a registry fetch. Manual `hack global up` prepares missing images. Local image contents remain trusted engine state; this does not claim registry-tag or image-digest verification. +Machine-wide `autostart_global` progress is local-only and is omitted from the +retained unsupported gateway's project responses. Those responses keep their +existing enabled-project row filter. diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 07293640c..f7e455762 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -156,3 +156,4 @@ empty directories where a plugin could be planted after an audit. Direnv files such as `.envrc` are not dotenv overlays and do not block admission. Prepare Caddy/CoreDNS images with manual `hack global up`: login routing never pulls or builds images and refuses missing local images. +Machine-wide autostart progress is exposed locally, not through the unsupported gateway. diff --git a/src/daemon/gateway-projects-payload.ts b/src/daemon/gateway-projects-payload.ts new file mode 100644 index 000000000..028f47295 --- /dev/null +++ b/src/daemon/gateway-projects-payload.ts @@ -0,0 +1,21 @@ +/** Keep machine-wide login progress local when projecting gateway-visible projects. */ +export function gatewayProjectsPayload< + T extends { + readonly projects: readonly Record[]; + readonly autostart_global: unknown; + }, +>(opts: { + readonly payload: T; + readonly enabledProjectIds: ReadonlySet; +}) { + const { autostart_global: _localAutostart, ...payload } = opts.payload; + return { + ...payload, + include_unregistered: false, + projects: payload.projects.filter( + (project) => + typeof project.project_id === "string" && + opts.enabledProjectIds.has(project.project_id) + ), + }; +} diff --git a/src/daemon/server.ts b/src/daemon/server.ts index b6a8050bf..1e36b74e5 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -39,6 +39,7 @@ import { shouldRefreshForDockerEvent, startDockerEventWatcher, } from "./docker-events.ts"; +import { gatewayProjectsPayload } from "./gateway-projects-payload.ts"; import { createDaemonLogger } from "./logger.ts"; import { createLoginAutostart, @@ -749,18 +750,7 @@ async function handleGatewayRequest(opts: { includeUnregistered: false, includeMeta, }); - const filtered = payload.projects.filter((project) => { - if (!project || typeof project !== "object") { - return false; - } - const id = (project as Record).project_id; - return typeof id === "string" && enabledProjectIds.has(id); - }); - return jsonResponse({ - ...payload, - include_unregistered: false, - projects: filtered, - }); + return jsonResponse(gatewayProjectsPayload({ payload, enabledProjectIds })); } const response = await handleRequest({ diff --git a/tests/gateway-projects-payload.test.ts b/tests/gateway-projects-payload.test.ts new file mode 100644 index 000000000..0570ee298 --- /dev/null +++ b/tests/gateway-projects-payload.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { gatewayProjectsPayload } from "../src/daemon/gateway-projects-payload.ts"; + +test("gateway projection omits registry-wide login activity and disabled project rows", () => { + const hidden = "hidden-project-id"; + const payload = { + generated_at: "fixture-time", + include_unregistered: true, + projects: [ + { project_id: "visible", name: "visible-project" }, + { project_id: hidden, name: "hidden-project" }, + { name: "unregistered" }, + ], + autostart_global: { + paused: false, + login_run: { + pending: [hidden], + started: [hidden], + failed: [hidden], + in_progress: true, + }, + }, + }; + for (const enabledProjectIds of [new Set(["visible"]), new Set()]) { + const projected = gatewayProjectsPayload({ payload, enabledProjectIds }); + const encoded = JSON.stringify(projected); + expect(encoded).not.toContain(hidden); + expect(encoded).not.toContain("autostart_global"); + expect(projected.projects).toHaveLength(enabledProjectIds.size); + expect(projected.generated_at).toBe("fixture-time"); + expect(projected.include_unregistered).toBe(false); + } + expect(payload.autostart_global.login_run.pending).toEqual([hidden]); + expect(payload.projects).toHaveLength(3); +}); From ffac4c6779de7156bf2b2414d0d3433444fbe4e3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 13:39:45 +0000 Subject: [PATCH 35/44] fix(autostart): refuse ineligible projects before Docker admission --- docs/always-on-projects.md | 3 +++ src/commands/project-autostart-intent.ts | 2 +- tests/native-compose-adoption-generation-retained.test.ts | 7 ++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 4b4a3ff22..bbb82704c 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -433,3 +433,6 @@ trusted engine state; this does not claim registry-tag or image-digest verificat Machine-wide `autostart_global` progress is local-only and is omitted from the retained unsupported gateway's project responses. Those responses keep their existing enabled-project row filter. +For a trusted checkout, project admission runs before Docker transport admission: +projects requiring manual starts are refused without consulting an engine they +cannot use. Eligible projects still pass Docker trust checks before startup. diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 220faaa4f..09342f0f1 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -354,8 +354,8 @@ async function assertLoginCheckoutBeforeDiscovery(opts: { }); } assertAutostartCheckoutTrusted(project); - assertAutostartDockerConfigTrusted({ home: homedir() }); await assertAutostartStartPolicy(project); + assertAutostartDockerConfigTrusted({ home: homedir() }); } function assertLoginProjectIdentity(opts: { diff --git a/tests/native-compose-adoption-generation-retained.test.ts b/tests/native-compose-adoption-generation-retained.test.ts index 31fe4bca0..e618f0e84 100644 --- a/tests/native-compose-adoption-generation-retained.test.ts +++ b/tests/native-compose-adoption-generation-retained.test.ts @@ -1,5 +1,5 @@ import { test as boundedTest, expect, spyOn } from "bun:test"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { resolveFinalAutostartDownTarget, @@ -558,6 +558,11 @@ async function expectAdoptionLoginRefused() { if (!home) { throw new Error("Fixture HOME required"); } + // Adoption admission must win even when the host's Docker selection is unsafe. + await mkdir(join(home, ".docker"), { mode: 0o700, recursive: true }); + const dockerConfig = join(home, ".docker/config.json"); + await writeFile(dockerConfig, "{}", { mode: 0o600 }); + await chmod(dockerConfig, 0o666); await mkdir(join(home, ".hack"), { mode: 0o700, recursive: true }); await writeFile( join(home, ".hack/projects.json"), From 19c25e17f1d2e212e2bbb261acc4cfef6eb089cd Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:33:09 +0000 Subject: [PATCH 36/44] fix(doctor): classify autostart login checks as machine scoped --- src/lib/doctor-metadata.ts | 1 + tests/doctor-metadata.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts index 409e7440c..8dfb7fc42 100644 --- a/src/lib/doctor-metadata.ts +++ b/src/lib/doctor-metadata.ts @@ -70,6 +70,7 @@ const MACHINE_AREAS: Readonly> = { "global files": "routing_dns", daemon: "daemon", "daemon service": "daemon", + "autostart login service": "daemon", "gateway config": "other", "gateway tokens": "other", "linux dns route": "routing_dns", diff --git a/tests/doctor-metadata.test.ts b/tests/doctor-metadata.test.ts index 8f322a3b6..0a5327877 100644 --- a/tests/doctor-metadata.test.ts +++ b/tests/doctor-metadata.test.ts @@ -13,6 +13,7 @@ test("doctor metadata distinguishes machine and project findings", () => { "network:hack-dev subnet", "resolver:hack", "daemon service", + "autostart login service", ]) { expect(doctorMetadata({ id }).scope).toBe("machine"); } From ceda45dd4cde84e346d7e8fa31e86fb3fdcfdb75 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:54:49 +0000 Subject: [PATCH 37/44] fix(autostart): accept macOS admin app directories and pause before an engine-blind down On macOS the automatic engine path (helper lookup, Docker plugins, socket path) now accepts a root-owned directory whose only extra writer is group admin, such as /Applications. World-writable directories and all checkout, config, registry and TLS inputs keep the strict check; the hackd.service PATH check is unchanged. When down cannot read the engine to choose between the base and a branch instance, it now pauses the marked candidate before failing, so a login pass still waiting for Docker cannot start what that down meant to stop. Co-Authored-By: Claude Opus 5.5 --- docs/always-on-projects.md | 24 +++++--- src/commands/project-autostart-intent.ts | 31 +++++++++- src/daemon/systemd.ts | 23 ++++++- src/lib/autostart-docker-trust.ts | 16 ++++- src/lib/autostart-executable-trust.ts | 3 + src/lib/path-trust.ts | 60 +++++++++++++++++-- tests/autostart-down-target.test.ts | 55 +++++++++++++++++ tests/autostart-executable-trust.test.ts | 41 +++++++++++++ .../tla/autostart-start-stop/COVERAGE.md | 2 +- 9 files changed, 234 insertions(+), 21 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index bbb82704c..801d54b7e 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -18,7 +18,10 @@ a branch instance uses the primary checkout's code, not a different Git checkout changes the marker; it does not start anything immediately. `hack down` pauses autostart for the selected marked instance before attempting to -stop it. For a renamed linked-worktree branch, ownership reconciliation selects +stop it. If `down` cannot reach the engine while it chooses between the base and +a branch instance (for example while the login pass is still waiting for Docker), +it pauses the marked base or current-branch instance before it fails, so the +waiting pass never starts what that `down` meant to stop. For a renamed linked-worktree branch, ownership reconciliation selects the existing instance before its pause is written; the same selected branch is used for the start/stop lock and teardown. Start and stop serialize on a machine-local instance lock: `down` waits for an in-flight login start, then stops it before returning success. The login @@ -77,8 +80,14 @@ checks Darwin ACLs on lookup directories, programs and symlink chains, checkout and env inputs, Docker config and plugins, registries and locks, routing assets and TLS trust files; mutation grants, unknown rights or an unsuccessful bounded inspection refuse startup. -It also applies to group-writable Homebrew directories, including group `admin`: install -required helpers in a trusted lookup directory before retrying. A refused search +It also applies to group-writable Homebrew directories owned by a user, including group `admin`: install +required helpers in a trusted lookup directory before retrying. +One macOS exception applies to the engine path only (helper lookup, Docker +plugins and the Docker socket path): a root-owned directory whose only extra +writer is group `admin`, such as `/Applications` (`root:admin`, mode `0775`), +is accepted. Administrators can already replace system software, so this adds +no new writer. A world-writable directory is still refused, and checkout, +config, registry and TLS inputs never get this exception. A refused search path does not consume a project's attempt for this boot. The daemon keeps its installed PATH and continues normal status observation; automatic-start refusal does not change ordinary daemon operation. @@ -102,11 +111,10 @@ as trusted paths. Hack never changes those permissions or files automatically. Use `umask 022` in shells that run `hack up` so changed generated inputs retain safe modes; with umask 002, a later manual start can recreate group-writable generated files and require another review before automatic startup. -This restriction can also affect Docker Desktop and OrbStack: their CLI and -Compose plugin links can point into app bundles beneath group-writable -`/Applications`. Such installations are refused by the automatic path even when -manual Docker works. Do not change `/Applications` permissions as an automatic -repair; use reviewed private tools or keep starting projects manually. +Docker Desktop and OrbStack link their CLI and Compose plugin into app bundles +beneath `/Applications`; that default layout is accepted by the exception above. +The app bundle itself must still be owned by you or root and not writable by +others. This unit supports Compose-only unattended starts. Projects declaring startup or lifecycle hooks, host processes, shared helpers or dependency bootstrap require manual `hack up`. The login child never executes those commands, even if the diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 09342f0f1..581a3bd5a 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -68,6 +68,7 @@ export async function resolveFinalAutostartDownTarget( readonly readProjectsRegistry?: typeof readProjectsRegistry; readonly readRuntimeProjects?: typeof readRuntimeProjects; readonly isLocalTarget?: (project: ProjectContext) => Promise; + readonly setProjectAutostartPaused?: typeof setProjectAutostartPaused; } ): Promise { const registry = await (deps?.readProjectsRegistry ?? readProjectsRegistry)(); @@ -75,7 +76,7 @@ export async function resolveFinalAutostartDownTarget( if (!context) { return undefined; } - const { project } = context; + const { project, registration } = context; const cfg = await readProjectConfig(project); const raw = opts.branch?.trim(); const explicitBranch = raw ? sanitizeBranchSlug(raw) || "branch" : null; @@ -105,9 +106,17 @@ export async function resolveFinalAutostartDownTarget( includeGlobal: false, }); if (!runtime.ok) { + // A login pass may be waiting for this same engine. Stop intent cannot + // wait for it: pause whichever candidate instance (base or this branch) + // is marked, so the pass never starts what this down meant to stop. + const paused = await pauseCandidateDownMarker({ + registration, + branch: effective.branch, + pause: deps?.setProjectAutostartPaused ?? setProjectAutostartPaused, + }); throw new HackCliError({ code: "E_DOCKER_UNAVAILABLE", - message: `Cannot inspect runtime ownership before down: ${runtime.error}`, + message: `Cannot inspect runtime ownership before down: ${runtime.error}${paused ? " Automatic startup for this project is paused; run hack up to resume it." : ""}`, }); } return { @@ -121,6 +130,24 @@ export async function resolveFinalAutostartDownTarget( }; } +/** Pause the marked base or current-branch instance an unresolved down could target. */ +async function pauseCandidateDownMarker(opts: { + readonly registration: RegisteredProject | undefined; + readonly branch: string | null; + readonly pause: typeof setProjectAutostartPaused; +}): Promise { + const marker = opts.registration?.autostart; + if ( + !(opts.registration && marker) || + marker.paused || + !(marker.branch === null || marker.branch === opts.branch) + ) { + return false; + } + await opts.pause({ id: opts.registration.id, paused: true }); + return true; +} + /** Shared legacy discovery never crosses native inputs or guesses a duplicate named registration. */ async function discoverAutostartCommandContext( opts: AutostartCommandSelection & { readonly registry: ProjectsRegistry } diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index eacbdeb3f..bdd1bfa19 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -2012,8 +2012,11 @@ export async function checkServicePathDirectories(opts: { readonly uid?: number; /** Optional inventory for additional read-only platform checks; never a trust cache. */ readonly inspectedPaths?: Set; + /** Autostart's engine lookup only; the hackd.service PATH check never sets it. */ + readonly acceptDarwinAdminDirectories?: boolean; }): Promise { const uid = opts.uid ?? process.getuid?.() ?? -1; + const admin = opts.acceptDarwinAdminDirectories === true; const checked = new Set(); const scanned = new Set(); for (const dir of opts.dirs) { @@ -2028,12 +2031,15 @@ export async function checkServicePathDirectories(opts: { hackHome: null, allowMissing: true, checked: directoryPaths, + acceptDarwinAdminDirectories: admin, }); if ("error" in walk) { return `the hackd.service PATH directory ${dir} is not safe: ${walk.error}`; } recordInspectedPaths(directoryPaths, opts.inspectedPaths); - const writable = lettingOthersCreate(walk.existing); + const writable = lettingOthersCreate(walk.existing, { + acceptDarwinAdminDirectories: admin, + }); if (writable !== null) { return `other users can ${walk.complete ? "add programs to" : "create"} the hackd.service PATH directory ${dir} (${walk.existing}, ${writable}); run: chmod go-w ${walk.existing}`; } @@ -2045,6 +2051,7 @@ export async function checkServicePathDirectories(opts: { uid, checked, inspectedPaths: opts.inspectedPaths, + acceptDarwinAdminDirectories: admin, }); if (program !== null) { return `the hackd.service PATH directory ${dir} holds ${program}`; @@ -2081,6 +2088,7 @@ async function checkPathDirectoryPrograms(opts: { readonly uid: number; readonly checked: Set; readonly inspectedPaths?: Set; + readonly acceptDarwinAdminDirectories?: boolean; }): Promise { let entries: string[]; let systemDir: boolean; @@ -2105,7 +2113,12 @@ async function checkPathDirectoryPrograms(opts: { continue; } const error = info.isSymbolicLink() - ? await checkPathLink({ path, uid: opts.uid, checked: opts.checked }) + ? await checkPathLink({ + path, + uid: opts.uid, + checked: opts.checked, + acceptDarwinAdminDirectories: opts.acceptDarwinAdminDirectories, + }) : programFileProblem({ path, info, uid: opts.uid, systemDir }); if (error !== null) { return error; @@ -2126,6 +2139,7 @@ async function checkPathLink(opts: { readonly path: string; readonly uid: number; readonly checked: Set; + readonly acceptDarwinAdminDirectories?: boolean; }): Promise { const walk = await walkReplaceableChain({ path: opts.path, @@ -2133,6 +2147,7 @@ async function checkPathLink(opts: { hackHome: null, allowMissing: true, checked: opts.checked, + acceptDarwinAdminDirectories: opts.acceptDarwinAdminDirectories, }); if ("error" in walk) { return walk.error; @@ -2140,7 +2155,9 @@ async function checkPathLink(opts: { if (walk.complete) { return null; } - const creatable = lettingOthersCreate(walk.existing); + const creatable = lettingOthersCreate(walk.existing, { + acceptDarwinAdminDirectories: opts.acceptDarwinAdminDirectories, + }); return creatable === null ? null : `${opts.path}, a link to a missing target that other users could create in ${walk.existing} (${creatable})`; diff --git a/src/lib/autostart-docker-trust.ts b/src/lib/autostart-docker-trust.ts index addb59313..07c491ebc 100644 --- a/src/lib/autostart-docker-trust.ts +++ b/src/lib/autostart-docker-trust.ts @@ -10,7 +10,7 @@ import { join } from "node:path"; import { assertAutostartPathAclsTrustedSync } from "./autostart-path-acl.ts"; import { HackCliError } from "./cli-result.ts"; import { isRecord } from "./guards.ts"; -import { walkReplaceableChain } from "./path-trust.ts"; +import { isDarwinAdminDirectory, walkReplaceableChain } from "./path-trust.ts"; const LOGIN_DOCKER_TRANSPORT = /^unix:\/\//; @@ -40,13 +40,18 @@ export function assertAutostartDockerConfigTrusted(opts: { hackHome: null, checked, allowMissing: true, + acceptDarwinAdminDirectories: true, }); if ("error" in trust) { throw new Error("Unsafe selection input"); } // Sticky directories protect existing names, not absent plugin/config names. const existing = statSync(trust.existing); - if (existing.isDirectory() && (existing.mode & 0o022) !== 0) { + if ( + existing.isDirectory() && + (existing.mode & 0o022) !== 0 && + !isDarwinAdminDirectory(existing) + ) { throw new Error("Selection directory permits new entries"); } if (!trust.complete) { @@ -174,6 +179,7 @@ function assertLoginDockerTransport(host: unknown, uid: number): void { hackHome: null, checked, allowMissing: true, + acceptDarwinAdminDirectories: true, }); if ("error" in trust) { throw new Error("Unsafe socket path"); @@ -183,7 +189,11 @@ function assertLoginDockerTransport(host: unknown, uid: number): void { continue; } const info = lstatSync(entry); - if (info.isDirectory() && (info.mode & 0o022) !== 0) { + if ( + info.isDirectory() && + (info.mode & 0o022) !== 0 && + !isDarwinAdminDirectory(info) + ) { throw new Error("Replaceable socket parent"); } } diff --git a/src/lib/autostart-executable-trust.ts b/src/lib/autostart-executable-trust.ts index 9906660f3..2175a8120 100644 --- a/src/lib/autostart-executable-trust.ts +++ b/src/lib/autostart-executable-trust.ts @@ -20,6 +20,9 @@ export async function assertAutostartExecutablePathTrusted( (await checkServicePathDirectories({ dirs: opts.path.split(":"), inspectedPaths, + // Docker Desktop and OrbStack reach their tools through + // /Applications (root:admin 0775); accept that, never o+w. + acceptDarwinAdminDirectories: true, })) === null ) { await assertAutostartPathAclsTrusted(inspectedPaths, deps); diff --git a/src/lib/path-trust.ts b/src/lib/path-trust.ts index 260be34b3..cd09c8584 100644 --- a/src/lib/path-trust.ts +++ b/src/lib/path-trust.ts @@ -41,6 +41,11 @@ export function checkReplaceableChain(opts: { * under the same directories (a whole PATH directory). */ readonly checked?: Set; + /** + * Autostart engine lookup only: also accept a macOS administrator directory + * ({@link isDarwinAdminDirectory}), such as `/Applications`. + */ + readonly acceptDarwinAdminDirectories?: boolean; }): string | null { const walk = walkReplaceableChain(opts); return "error" in walk ? walk.error : null; @@ -60,6 +65,11 @@ export function walkReplaceableChain(opts: { readonly stopAtLink?: boolean; readonly allowMissing?: boolean; readonly checked?: Set; + /** + * Autostart engine lookup only: also accept a macOS administrator directory + * ({@link isDarwinAdminDirectory}), such as `/Applications`. + */ + readonly acceptDarwinAdminDirectories?: boolean; }): | { readonly error: string } | { readonly existing: string; readonly complete: boolean } { @@ -113,6 +123,7 @@ type ChainWalk = { readonly hackHome: string | null; readonly stopAtLink?: boolean; readonly allowMissing?: boolean; + readonly acceptDarwinAdminDirectories?: boolean; }; readonly checked: Set; readonly queue: string[]; @@ -129,6 +140,7 @@ function checkChainEntry(state: ChainWalk, entry: string): string | null { path: entry, uid: state.opts.uid, hackHome: state.opts.hackHome, + acceptDarwinAdminDirectories: state.opts.acceptDarwinAdminDirectories, }); } @@ -201,6 +213,7 @@ function checkServicePathEntry(opts: { readonly path: string; readonly uid: number; readonly hackHome: string | null; + readonly acceptDarwinAdminDirectories?: boolean; }): string | null { let info: Stats; try { @@ -232,7 +245,11 @@ function checkServicePathEntry(opts: { return null; } const sticky = info.isDirectory() && (info.mode & 0o1000) !== 0; - if ((info.mode & 0o022) === 0 || sticky) { + if ( + (info.mode & 0o022) === 0 || + sticky || + (opts.acceptDarwinAdminDirectories === true && isDarwinAdminDirectory(info)) + ) { return null; } // Only a directory inside the Hack home is repaired: whoever could write a @@ -254,9 +271,44 @@ function checkServicePathEntry(opts: { } /** "mode NNNN" when others may create entries in `dir` (sticky or not). */ -export function lettingOthersCreate(dir: string): string | null { - const mode = statSync(dir).mode; - return (mode & 0o022) === 0 ? null : `mode ${(mode & 0o7777).toString(8)}`; +export function lettingOthersCreate( + dir: string, + opts: { readonly acceptDarwinAdminDirectories?: boolean } = {} +): string | null { + const info = statSync(dir); + const mode = info.mode; + if ( + (mode & 0o022) === 0 || + (opts.acceptDarwinAdminDirectories === true && isDarwinAdminDirectory(info)) + ) { + return null; + } + return `mode ${(mode & 0o7777).toString(8)}`; +} + +/** The fixed group id of macOS's `admin` group. */ +export const DARWIN_ADMIN_GID = 80; + +/** + * A macOS administrator directory such as `/Applications` (root:admin 0775): + * a root-owned directory whose only extra writer is the `admin` group, never + * other users. Administrators can already replace system software, so on the + * autostart engine path this grants no authority the user lacks. Accepted + * only where a caller opts in, and only on macOS. + */ +export function isDarwinAdminDirectory( + info: Pick & { + readonly isDirectory: () => boolean; + }, + platform: NodeJS.Platform = process.platform +): boolean { + return ( + platform === "darwin" && + info.isDirectory() && + info.uid === 0 && + info.gid === DARWIN_ADMIN_GID && + (Number(info.mode) & 0o002) === 0 + ); } /** diff --git a/tests/autostart-down-target.test.ts b/tests/autostart-down-target.test.ts index a41279da4..7dd671c85 100644 --- a/tests/autostart-down-target.test.ts +++ b/tests/autostart-down-target.test.ts @@ -188,6 +188,61 @@ test("unavailable runtime refuses before a final target is published", async () ).rejects.toMatchObject({ code: "E_DOCKER_UNAVAILABLE" }); }); +for (const marked of ["old-branch", "new-branch", null, "other-branch"]) { + test(`unavailable runtime still pauses a ${marked ?? "base"} marker that this down could target`, async () => { + const paused: { id: string; paused: boolean }[] = []; + const registration = { + id: "example-id", + name: "example", + repoRoot: primary, + projectDir: join(primary, ".hack"), + projectDirName: ".hack" as const, + createdAt: "2026-01-01T00:00:00Z", + worktrees: [ + { + path: worktree, + branch: "new-branch", + lastSeenAt: "2026-01-01T00:00:00Z", + }, + ], + autostart: { + branch: marked, + paused: false, + lastResult: null, + lastAttemptBootId: null, + }, + }; + await expect( + resolveFinalAutostartDownTarget( + { cwd: worktree }, + { + ...dependencies([]), + readProjectsRegistry: async () => ({ + version: 1 as const, + projects: [registration], + }), + readRuntimeProjects: async () => ({ + ok: false, + runtime: [], + error: "fixture unavailable", + checkedAtMs: 1, + }), + setProjectAutostartPaused: async (input) => { + paused.push({ id: input.id ?? "", paused: input.paused }); + return []; + }, + } + ) + ).rejects.toMatchObject({ code: "E_DOCKER_UNAVAILABLE" }); + // The implicit target is either base or the worktree's current branch. + expect(paused).toEqual( + marked === null || marked === "new-branch" + ? [{ id: "example-id", paused: true }] + : [] + ); + }); +} + for (const ownedBranch of ["old-branch", null]) { test(`source down pauses and locks the owned ${ownedBranch ?? "base"} runtime after a real worktree branch rename`, async () => { const ownedComposeProject = diff --git a/tests/autostart-executable-trust.test.ts b/tests/autostart-executable-trust.test.ts index 22c48f527..74cee8106 100644 --- a/tests/autostart-executable-trust.test.ts +++ b/tests/autostart-executable-trust.test.ts @@ -12,6 +12,10 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { assertAutostartExecutablePathTrusted } from "../src/lib/autostart-executable-trust.ts"; +import { + DARWIN_ADMIN_GID, + isDarwinAdminDirectory, +} from "../src/lib/path-trust.ts"; let root = ""; let bin = ""; @@ -202,3 +206,40 @@ test("Darwin ACL probe failures refuse with closed diagnostics", async () => { ); } }); + +test("macOS admin directories such as /Applications are accepted only on darwin, never world-writable", () => { + const dir = (fields: { uid: number; gid: number; mode: number }) => ({ + ...fields, + isDirectory: () => true, + }); + // /Applications: root:admin drwxrwxr-x. + const applications = dir({ uid: 0, gid: DARWIN_ADMIN_GID, mode: 0o4_0775 }); + expect(isDarwinAdminDirectory(applications, "darwin")).toBe(true); + expect(isDarwinAdminDirectory(applications, "linux")).toBe(false); + expect( + isDarwinAdminDirectory( + dir({ uid: 0, gid: DARWIN_ADMIN_GID, mode: 0o4_0777 }), + "darwin" + ) + ).toBe(false); + expect( + isDarwinAdminDirectory(dir({ uid: 0, gid: 20, mode: 0o4_0775 }), "darwin") + ).toBe(false); + expect( + isDarwinAdminDirectory( + dir({ uid: 501, gid: DARWIN_ADMIN_GID, mode: 0o4_0775 }), + "darwin" + ) + ).toBe(false); + expect( + isDarwinAdminDirectory( + { + uid: 0, + gid: DARWIN_ADMIN_GID, + mode: 0o10_0775, + isDirectory: () => false, + }, + "darwin" + ) + ).toBe(false); +}); diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md index d38718d44..3473d0f2a 100644 --- a/tests/models/tla/autostart-start-stop/COVERAGE.md +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -14,7 +14,7 @@ separate transitions; they are not collapsed into one atomic command. | Model action | Source boundary | Regression mapping | | --- | --- | --- | -| `DownPause` | `handleDown` first pins the reconciled runtime branch, then publishes pause through `updateAutostartIntent` before waiting for that instance lock. | `tests/autostart-lifecycle-intent.test.ts`: source down pauses before waiting for a start and holds admission through runtime shutdown. | +| `DownPause` | `handleDown` first pins the reconciled runtime branch, then publishes pause through `updateAutostartIntent` before waiting for that instance lock. When the engine cannot be read to pin an implicit worktree target, `resolveFinalAutostartDownTarget` pauses the marked base or current-branch instance before failing. | `tests/autostart-lifecycle-intent.test.ts`: source down pauses before waiting for a start and holds admission through runtime shutdown. `tests/autostart-down-target.test.ts`: unavailable runtime still pauses a candidate marker. | | `LoginLock` / `LoginAdmit` / `LoginRefuse` | `withAutostartCommandLock` and `withAutostartProjectLock` serialize the actual CLI child; `assertAutostartProjectCurrent` rereads trusted intent while admission is held. | The same source-down regression rejects the earlier start after pause and a queued later start after successful down. | | `ChildStart` / `ChildSettle` | Runtime startup effects execute inside the login child's project lock; the owning command settles before lock release. A child admitted just before pause may finish before down obtains the lock. | `tests/autostart-project-lock.test.ts`: start/stop admission serializes the Compose instance even after registry identity changes. | | `DownLock` / `DownStop` / `DownReturn` | Foreground down holds the same instance lock through runtime shutdown and successful return. | The source-down regression blocks a queued start while fake shutdown is still running. | From 7cdb616dba27e55423c7af37c13d7aa223ba9c58 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:58:28 +0000 Subject: [PATCH 38/44] fix(autostart): pause a renamed worktree's marker when down cannot read the engine Without runtime evidence an implicit down from a renamed linked worktree may still target its old branch instance, so pause the registration's marker whatever its branch before failing. Co-Authored-By: Claude Opus 5.5 --- docs/always-on-projects.md | 3 ++- src/commands/project-autostart-intent.ts | 20 +++++++++---------- tests/autostart-down-target.test.ts | 11 ++++------ .../tla/autostart-start-stop/COVERAGE.md | 2 +- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 801d54b7e..cc65fd4e5 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -20,7 +20,8 @@ changes the marker; it does not start anything immediately. `hack down` pauses autostart for the selected marked instance before attempting to stop it. If `down` cannot reach the engine while it chooses between the base and a branch instance (for example while the login pass is still waiting for Docker), -it pauses the marked base or current-branch instance before it fails, so the +it pauses that project's marked instance (whatever its branch, since a renamed +worktree may still own an old one) before it fails, so the waiting pass never starts what that `down` meant to stop. For a renamed linked-worktree branch, ownership reconciliation selects the existing instance before its pause is written; the same selected branch is used for the start/stop lock and teardown. Start and stop serialize on a machine-local instance lock: `down` waits diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index 581a3bd5a..ed8110086 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -107,11 +107,11 @@ export async function resolveFinalAutostartDownTarget( }); if (!runtime.ok) { // A login pass may be waiting for this same engine. Stop intent cannot - // wait for it: pause whichever candidate instance (base or this branch) - // is marked, so the pass never starts what this down meant to stop. + // wait for it: without runtime evidence any marked instance of this + // registration (base, this branch, or a renamed worktree's old branch) + // may be the target, so pause it before failing. const paused = await pauseCandidateDownMarker({ registration, - branch: effective.branch, pause: deps?.setProjectAutostartPaused ?? setProjectAutostartPaused, }); throw new HackCliError({ @@ -130,18 +130,18 @@ export async function resolveFinalAutostartDownTarget( }; } -/** Pause the marked base or current-branch instance an unresolved down could target. */ +/** + * Pause the registration's marked instance when an implicit down cannot read + * the engine. Over-pausing a different branch is the safe failure: a later + * `hack up` resumes it, whereas a missed pause lets a waiting login pass start + * what this down meant to stop. + */ async function pauseCandidateDownMarker(opts: { readonly registration: RegisteredProject | undefined; - readonly branch: string | null; readonly pause: typeof setProjectAutostartPaused; }): Promise { const marker = opts.registration?.autostart; - if ( - !(opts.registration && marker) || - marker.paused || - !(marker.branch === null || marker.branch === opts.branch) - ) { + if (!(opts.registration && marker) || marker.paused) { return false; } await opts.pause({ id: opts.registration.id, paused: true }); diff --git a/tests/autostart-down-target.test.ts b/tests/autostart-down-target.test.ts index 7dd671c85..16da5c95c 100644 --- a/tests/autostart-down-target.test.ts +++ b/tests/autostart-down-target.test.ts @@ -189,7 +189,7 @@ test("unavailable runtime refuses before a final target is published", async () }); for (const marked of ["old-branch", "new-branch", null, "other-branch"]) { - test(`unavailable runtime still pauses a ${marked ?? "base"} marker that this down could target`, async () => { + test(`unavailable runtime still pauses a ${marked ?? "base"} marker of this registration, including a renamed branch`, async () => { const paused: { id: string; paused: boolean }[] = []; const registration = { id: "example-id", @@ -234,12 +234,9 @@ for (const marked of ["old-branch", "new-branch", null, "other-branch"]) { } ) ).rejects.toMatchObject({ code: "E_DOCKER_UNAVAILABLE" }); - // The implicit target is either base or the worktree's current branch. - expect(paused).toEqual( - marked === null || marked === "new-branch" - ? [{ id: "example-id", paused: true }] - : [] - ); + // Without runtime evidence a renamed worktree may still own its old + // branch, so every marker of this registration is paused. + expect(paused).toEqual([{ id: "example-id", paused: true }]); }); } diff --git a/tests/models/tla/autostart-start-stop/COVERAGE.md b/tests/models/tla/autostart-start-stop/COVERAGE.md index 3473d0f2a..4d7f7ae25 100644 --- a/tests/models/tla/autostart-start-stop/COVERAGE.md +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -14,7 +14,7 @@ separate transitions; they are not collapsed into one atomic command. | Model action | Source boundary | Regression mapping | | --- | --- | --- | -| `DownPause` | `handleDown` first pins the reconciled runtime branch, then publishes pause through `updateAutostartIntent` before waiting for that instance lock. When the engine cannot be read to pin an implicit worktree target, `resolveFinalAutostartDownTarget` pauses the marked base or current-branch instance before failing. | `tests/autostart-lifecycle-intent.test.ts`: source down pauses before waiting for a start and holds admission through runtime shutdown. `tests/autostart-down-target.test.ts`: unavailable runtime still pauses a candidate marker. | +| `DownPause` | `handleDown` first pins the reconciled runtime branch, then publishes pause through `updateAutostartIntent` before waiting for that instance lock. When the engine cannot be read to pin an implicit worktree target, `resolveFinalAutostartDownTarget` pauses the registration's marked instance (any branch) before failing. | `tests/autostart-lifecycle-intent.test.ts`: source down pauses before waiting for a start and holds admission through runtime shutdown. `tests/autostart-down-target.test.ts`: unavailable runtime still pauses a candidate marker. | | `LoginLock` / `LoginAdmit` / `LoginRefuse` | `withAutostartCommandLock` and `withAutostartProjectLock` serialize the actual CLI child; `assertAutostartProjectCurrent` rereads trusted intent while admission is held. | The same source-down regression rejects the earlier start after pause and a queued later start after successful down. | | `ChildStart` / `ChildSettle` | Runtime startup effects execute inside the login child's project lock; the owning command settles before lock release. A child admitted just before pause may finish before down obtains the lock. | `tests/autostart-project-lock.test.ts`: start/stop admission serializes the Compose instance even after registry identity changes. | | `DownLock` / `DownStop` / `DownReturn` | Foreground down holds the same instance lock through runtime shutdown and successful return. | The source-down regression blocks a queued start while fake shutdown is still running. | From cc9427a91116260647409e50000d943bd2b54702 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 16:58:57 +0000 Subject: [PATCH 39/44] docs(autostart): reinstall a login service from an earlier version once Co-Authored-By: Claude Opus 5.5 --- docs/always-on-projects.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index cc65fd4e5..d43e7c00b 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -51,6 +51,10 @@ projects paused by a manual `down`; start one with `hack up` or explicitly ![Login startup sequence](diagrams/autostart-login.svg) Only a daemon launched by the installed login service runs the automatic pass. +A login service installed by an earlier Hack version does not carry the +login-service marker, so its daemon never runs the pass. After upgrading to this +version, run `hack daemon install` once to rewrite the service; `hack daemon start` +alone does not update it. A daemon started manually or implicitly by another CLI command does not replay projects. To run the pass deliberately, use `hack autostart run --no-interactive` (optionally `--json`). Both entry points respect the same per-boot claims. From 57f211bf31f85e22393e447250eef0f6998670bc Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:07:10 +0000 Subject: [PATCH 40/44] fix(autostart): report an unfinished env migration as a manual start at login After the env-migration journal landed on next, an unfinished or unverifiable `hack env migrate` surfaced in the login child as an unexpected error, which the pass recorded as a failed start. Check the journal before any startup work and report it as pending_env_migration. Co-Authored-By: Claude Opus 5.5 --- docs/always-on-projects.md | 3 ++- src/commands/project-autostart-intent.ts | 13 ++++++++++ tests/autostart-lifecycle-intent.test.ts | 32 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index d43e7c00b..b255bcc08 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -227,7 +227,8 @@ startup parses only this policy setting and never reports credential fields. No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a -recorded result. Run `hack up` manually to investigate and recover. The daemon does +recorded result. An unfinished or unverifiable `hack env migrate` journal is +reported as `pending_env_migration` before any startup work, not as a failed start. Run `hack up` manually to investigate and recover. The daemon does not install routing, start Docker, sign in, or migrate configuration at login. Before trusting intent, Hack opens `projects.json` without following links and requires a regular file owned by the current user, with no group or other write diff --git a/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts index ed8110086..ca6400e7d 100644 --- a/src/commands/project-autostart-intent.ts +++ b/src/commands/project-autostart-intent.ts @@ -18,6 +18,7 @@ import { assertAutostartStartPolicy } from "../lib/autostart-start-policy.ts"; import { resolveEffectiveBranch } from "../lib/branches.ts"; import { HackCliError } from "../lib/cli-result.ts"; import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; +import { assertEnvMigrationFinished } from "../lib/env-migration.ts"; import { pathExists } from "../lib/fs.ts"; import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; import { @@ -206,6 +207,18 @@ export async function assertAutostartEnvironment(opts: { readonly project: ProjectContext; }): Promise { const project = opts.project; + // An unfinished or unverifiable env migration needs its owner, not a retry: + // report it as the manual-start reason before any startup work. + try { + await assertEnvMigrationFinished(project); + } catch { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Autostart skipped: an env migration is unfinished or cannot be verified. Finish it with hack env migrate, then run hack up.", + detail: { reason: "pending_env_migration" }, + }); + } const contract = await pathExists( resolve(project.projectDir, PROJECT_ENV_CONTRACT_FILENAME) ); diff --git a/tests/autostart-lifecycle-intent.test.ts b/tests/autostart-lifecycle-intent.test.ts index a669ac61f..607f12220 100644 --- a/tests/autostart-lifecycle-intent.test.ts +++ b/tests/autostart-lifecycle-intent.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { chmod, mkdir, mkdtemp, + realpath, rm, stat, symlink, @@ -143,6 +145,36 @@ test("login refuses a pending legacy migration without touching dotenv or changi }); }); +test("login treats an unfinished env migration as a manual-start refusal, not a failed start", async () => { + await writeRegistry(false); + const projectRoot = await realpath(join(root, "project")); + const journalDir = join( + root, + "home", + ".hack", + "env-migrations", + createHash("sha256").update(projectRoot).digest("hex") + ); + await mkdir(journalDir, { recursive: true, mode: 0o700 }); + await writeFile( + join(journalDir, "journal.json"), + JSON.stringify({ + version: 1, + kind: "hack-env-migration", + project: { projectRoot }, + state: "applying", + }), + { mode: 0o600 } + ); + const result = await run(["up", "--json", "-d"], true); + expect(result.exitCode).not.toBe(0); + expect(classifyAutostartExit(result)).toEqual({ + status: "skipped", + reason: "pending_env_migration", + }); + expect((await marker()).paused).toBe(false); +}); + test("up --always registers and marks the selected primary instance before runtime dispatch", async () => { await writeFile( join(root, "home", ".hack", "projects.json"), From 93cda95b3ac87a135ecef82d0598081a5ab50557 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:18:23 +0000 Subject: [PATCH 41/44] fix(autostart): accept hack env materialize output at login admission A modern project whose .hack/.env was written by `hack env materialize` (env layers, no legacy contract, valid .env.state.json) was refused as pending_env_migration. Use the same generated-output rule as the legacy dotenv detector; unattended Compose never loads that file. Co-Authored-By: Claude Opus 5.5 --- docs/always-on-projects.md | 5 +++- src/lib/autostart-start-policy.ts | 14 ++++++++++- src/lib/project-env-config.ts | 25 ++++++++++++++++++++ tests/autostart-start-policy.test.ts | 35 ++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index b255bcc08..0aa01b690 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -228,7 +228,10 @@ startup parses only this policy setting and never reports credential fields. No terminal or keychain prompt is allowed. Missing keys, required interaction, pending env migration, unavailable project paths and startup failures produce a recorded result. An unfinished or unverifiable `hack env migrate` journal is -reported as `pending_env_migration` before any startup work, not as a failed start. Run `hack up` manually to investigate and recover. The daemon does +reported as `pending_env_migration` before any startup work, not as a failed start. +A `.hack/.env` written by `hack env materialize` (with its valid `.env.state.json` +and env layers) is generated output, not a pending migration; unattended Compose +does not load it. Run `hack up` manually to investigate and recover. The daemon does not install routing, start Docker, sign in, or migrate configuration at login. Before trusting intent, Hack opens `projects.json` without following links and requires a regular file owned by the current user, with no group or other write diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index 3c851ce9d..11f81ae91 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -24,7 +24,10 @@ import { type ProjectDirName, parseProjectConfigRecord, } from "./project.ts"; -import { isExcludedLegacyDotenvName } from "./project-env-config.ts"; +import { + isExcludedLegacyDotenvName, + isMaterializedBaseDotenv, +} from "./project-env-config.ts"; export type AutostartStartPolicyReason = | "host_commands_require_manual_start" @@ -295,7 +298,16 @@ async function envPolicyReason( if (inputs.length > MAX_ENV_FILES) { return "compose_inputs_require_manual_start"; } + // `hack env materialize` output is generated from the layers, which are + // checked below; unattended Compose never loads it (implicit dotenv off). + const materialized = + directory === opts.projectDir && + inputs.includes(".env") && + (await isMaterializedBaseDotenv({ projectDir: directory })); for (const name of inputs) { + if (materialized && name === ".env") { + continue; + } const text = await readPolicyText(resolve(directory, name), opts.budget); if (text === null) { return "compose_inputs_require_manual_start"; diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index 1350cc1bb..5882a8b02 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -4952,6 +4952,31 @@ export async function inspectProjectEnvMaterialization(opts: { }; } +/** + * Whether `.hack/.env` is `hack env materialize` output rather than a legacy + * input: the same rule {@link findUnmigratedLegacyDotenv} uses to skip the + * base file (env layers exist, no legacy contract, a valid state file). + */ +export async function isMaterializedBaseDotenv(opts: { + readonly projectDir: string; +}): Promise { + const entries = await readdir(opts.projectDir); + if ( + !entries.some( + (name) => + name.startsWith(PROJECT_ENV_CONFIG_FILENAME_PREFIX) && + name.endsWith(PROJECT_ENV_CONFIG_FILENAME_SUFFIX) + ) || + entries.includes(PROJECT_ENV_CONTRACT_FILENAME) + ) { + return false; + } + const state = await readProjectEnvStateFile({ + statePath: resolveProjectEnvStatePath({ projectDir: opts.projectDir }), + }); + return state.ok; +} + async function readProjectEnvStateFile(opts: { readonly statePath: string; }): Promise< diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts index 65cad8b4c..f4c84141a 100644 --- a/tests/autostart-start-policy.test.ts +++ b/tests/autostart-start-policy.test.ts @@ -470,6 +470,41 @@ test("legacy dotenv and contracts preserve the fixed pending migration refusal w } }); +test("hack env materialize output is not a pending migration, but an unproven .env still is", async () => { + await config({ name: "example", lifecycle: {} }); + await layer({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: { APP_MODE: "local" } }, + }); + const envPath = join(opts.projectDir, ".env"); + await writeFile(envPath, "APP_MODE=local\n", { mode: 0o600 }); + // Without a valid state file the same bytes are an unmigrated input. + expect(await autostartStartPolicyRefusal(opts)).toBe("pending_env_migration"); + await writeFile( + join(opts.projectDir, ".env.state.json"), + JSON.stringify({ + version: 1, + generatedAt: "2026-01-01T00:00:00.000Z", + selectedOverlay: null, + selectedService: null, + inputs: {}, + }), + { mode: 0o600 } + ); + const before = await snapshot(root); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); + expect(await snapshot(root)).toEqual(before); + // A legacy contract still marks the project as unmigrated. + await writeFile( + join(opts.projectDir, "hack.env.json"), + JSON.stringify({ version: 1, vars: [] }), + { mode: 0o600 } + ); + expect(await autostartStartPolicyRefusal(opts)).toBe("pending_env_migration"); +}); + test("dotenv templates and backups are not active inputs in either project directory", async () => { for (const directory of [opts.repoRoot, opts.projectDir]) { for (const suffix of [ From 278f847184728c30102f4d30e72ff020d00f28d8 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:40:18 +0000 Subject: [PATCH 42/44] feat(autostart): emit the machine-readable schema envelope for every autostart --json Follow the machine-readable output rule from #835 for these new commands: `hack autostart add|list|pause|remove|resume|run --json` now return `{ ok, data: { schema: "hack.autostart.v1", action, ... } }`; `list` no longer prints a bare object. The commands are added to the JSON inventory and the generated reference table. `projects --json` keeps its legacy shape. Co-Authored-By: Claude Opus 5.5 --- docs/always-on-projects.md | 6 ++- docs/reference/machine-readable-output.md | 6 +++ src/cli/json-contracts.ts | 12 +++++ src/commands/autostart.ts | 29 +++++++++--- src/lib/machine-output.ts | 1 + tests/autostart-cli.test.ts | 58 +++++++++++++---------- 6 files changed, 80 insertions(+), 32 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index 0aa01b690..f5e0835ef 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -40,8 +40,10 @@ hack autostart remove --project sample # forget the marker; leave services alon ``` The positional project spelling remains accepted. All commands accept -`--json --no-interactive`; mutations return the usual `{ok,data}` or -`{ok:false,error}` envelope. `list --json` returns a bare object. +`--json --no-interactive`. Every `hack autostart … --json` success, `list` +included, uses the [machine-readable envelope](reference/machine-readable-output.md): +`{"ok":true,"data":{"schema":"hack.autostart.v1","action":…,"autostart_global":…,"projects":[…]}}`; +failures use `{ok:false,error}`. Global pause also covers projects marked afterwards. Global resume preserves projects paused by a manual `down`; start one with `hack up` or explicitly `hack autostart resume --project sample` to clear that manual pause. diff --git a/docs/reference/machine-readable-output.md b/docs/reference/machine-readable-output.md index 560859956..453b58e42 100644 --- a/docs/reference/machine-readable-output.md +++ b/docs/reference/machine-readable-output.md @@ -52,6 +52,12 @@ success behavior stays covered by command-specific suites. | Canonical command / variant | Success shape | |---|---| | `hack activity --json` | Envelope; versioned `data.schema`: `hack.activity.v1` | +| `hack autostart add --json` | Envelope; versioned `data.schema`: `hack.autostart.v1` | +| `hack autostart list --json` | Envelope; versioned `data.schema`: `hack.autostart.v1` | +| `hack autostart pause --json` | Envelope; versioned `data.schema`: `hack.autostart.v1` | +| `hack autostart remove --json` | Envelope; versioned `data.schema`: `hack.autostart.v1` | +| `hack autostart resume --json` | Envelope; versioned `data.schema`: `hack.autostart.v1` | +| `hack autostart run --json` | Envelope; versioned `data.schema`: `hack.autostart.v1` | | `hack commands cancel --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | | `hack commands read --json` | NDJSON events / terminal event, not a single envelope | | `hack commands show --json` | Envelope (`ok`, `data` / `error`); legacy payload has no required schema | diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 1bd5047de..a7599f853 100644 --- a/src/cli/json-contracts.ts +++ b/src/cli/json-contracts.ts @@ -25,6 +25,12 @@ export const JSON_COMMAND_SHAPES = { logs: "logs", open: "legacy-object", activity: "schema-envelope", + "autostart add": "schema-envelope", + "autostart list": "schema-envelope", + "autostart pause": "schema-envelope", + "autostart remove": "schema-envelope", + "autostart resume": "schema-envelope", + "autostart run": "schema-envelope", doctor: "envelope", "daemon status": "legacy-object", "dispatch run": "legacy-object", @@ -86,6 +92,12 @@ export const JSON_COMMAND_SHAPES = { export const SCHEMA_JSON_VARIANTS = [ { command: "activity", flags: [], schema: "hack.activity.v1" }, + { command: "autostart add", flags: [], schema: "hack.autostart.v1" }, + { command: "autostart list", flags: [], schema: "hack.autostart.v1" }, + { command: "autostart pause", flags: [], schema: "hack.autostart.v1" }, + { command: "autostart remove", flags: [], schema: "hack.autostart.v1" }, + { command: "autostart resume", flags: [], schema: "hack.autostart.v1" }, + { command: "autostart run", flags: [], schema: "hack.autostart.v1" }, { command: "version", flags: [], schema: "hack.version.v1" }, { command: "projects", diff --git a/src/commands/autostart.ts b/src/commands/autostart.ts index 09278e93b..3237c82f1 100644 --- a/src/commands/autostart.ts +++ b/src/commands/autostart.ts @@ -22,10 +22,11 @@ import { readAutostartServiceReady, serializeAutostartGlobal, } from "../lib/autostart-view.ts"; -import { emitCliResult, okResult } from "../lib/cli-result.ts"; +import { emitCliResult } from "../lib/cli-result.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; import { isLinkedGitWorktree } from "../lib/git-worktree.ts"; import { resolveUserHomeDir } from "../lib/home-dir.ts"; +import { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; import { findProjectContext, sanitizeBranchSlug } from "../lib/project.ts"; import { ProjectInputSelectionError } from "../lib/project-input-selection.ts"; import { PROJECT_SELECTOR_PATTERN } from "../lib/project-name.ts"; @@ -42,6 +43,9 @@ import { } from "../lib/projects-registry.ts"; import { display } from "../ui/display.ts"; +/** Every `hack autostart … --json` success payload (machine-readable output rule). */ +export const AUTOSTART_JSON_SCHEMA = MACHINE_SCHEMAS.autostart; + const projectPositionals = [{ name: "project", required: false }] as const; const addSpec = defineCommand({ name: "add", @@ -123,7 +127,8 @@ async function writeMutation(opts: { await readAutostartServiceReady() ); emitCliResult({ - result: okResult({ + result: machineSuccess({ + schema: AUTOSTART_JSON_SCHEMA, data: { action: opts.action, autostart_global: global, @@ -342,9 +347,20 @@ const list: CommandHandlerFor = async ({ args }) => { await readAutostartServiceReady() ); if (args.options.json) { - process.stdout.write( - `${JSON.stringify({ autostart_global: global, projects: projects.map((project) => ({ project_id: project.id, name: project.name, autostart: projectAutostartFromGlobal({ project, global }) })) })}\n` - ); + emitCliResult({ + result: machineSuccess({ + schema: AUTOSTART_JSON_SCHEMA, + data: { + action: "list", + autostart_global: global, + projects: projects.map((project) => ({ + project_id: project.id, + name: project.name, + autostart: projectAutostartFromGlobal({ project, global }), + })), + }, + }), + }); return 0; } if (global.paused) { @@ -399,7 +415,8 @@ const run: CommandHandlerFor = async ({ args }) => { await readAutostartServiceReady() ); emitCliResult({ - result: okResult({ + result: machineSuccess({ + schema: AUTOSTART_JSON_SCHEMA, data: { action: "run", waiting_for_engine: result.waitingForEngine, diff --git a/src/lib/machine-output.ts b/src/lib/machine-output.ts index 1b8561c5c..fe15914aa 100644 --- a/src/lib/machine-output.ts +++ b/src/lib/machine-output.ts @@ -3,6 +3,7 @@ import { type CliOkResult, okResult } from "./cli-result.ts"; /** HACK-2218 machine contracts use an envelope and a versioned payload schema. */ export const MACHINE_SCHEMAS = { activity: "hack.activity.v1", + autostart: "hack.autostart.v1", version: "hack.version.v1", projectsObserve: "hack.projects.observe.v1", globalAdminPlan: "hack.global-admin-plan.v1", diff --git a/tests/autostart-cli.test.ts b/tests/autostart-cli.test.ts index 84c10cb20..f4ee0f867 100644 --- a/tests/autostart-cli.test.ts +++ b/tests/autostart-cli.test.ts @@ -2,7 +2,10 @@ import { afterAll, beforeAll, beforeEach, expect, spyOn, test } from "bun:test"; import { parsePositionalsForCommand } from "../src/cli/command.ts"; import { runCli } from "../src/cli/run.ts"; import { CLI_SPEC } from "../src/cli/spec.ts"; -import { autostartCommand } from "../src/commands/autostart.ts"; +import { + AUTOSTART_JSON_SCHEMA, + autostartCommand, +} from "../src/commands/autostart.ts"; import { __testOnlyProjectsCommand } from "../src/commands/projects.ts"; import { serializeAutostartGlobal } from "../src/lib/autostart-view.ts"; import { resetNoInteractiveFlagForTests } from "../src/lib/interactivity.ts"; @@ -219,31 +222,36 @@ test("autostart list JSON exposes stable policy and result fields without admiss }) ); expect(JSON.parse(result.output)).toEqual({ - autostart_global: serializeAutostartGlobal( - getDefaultAutostartGlobal(), - false - ), - projects: [ - { - project_id: "example-id", - name: "example", - autostart: { - enabled: true, - state: "on", - paused_reason: null, - failures: { - count: 0, - last_at: null, - failed_services: [], - last_error_code: null, + ok: true, + data: { + schema: AUTOSTART_JSON_SCHEMA, + action: "list", + autostart_global: serializeAutostartGlobal( + getDefaultAutostartGlobal(), + false + ), + projects: [ + { + project_id: "example-id", + name: "example", + autostart: { + enabled: true, + state: "on", + paused_reason: null, + failures: { + count: 0, + last_at: null, + failed_services: [], + last_error_code: null, + }, + max_failures: 3, + branch: null, + paused: false, + last_result: null, }, - max_failures: 3, - branch: null, - paused: false, - last_result: null, }, - }, - ], + ], + }, }); expect(result.output).not.toContain("private-admission-id"); expect(serializeProjectAutostart(undefined)).toEqual({ @@ -369,6 +377,7 @@ for (const action of ["add", "remove", "pause", "resume"]) { expect(JSON.parse(result.output)).toMatchObject({ ok: true, data: { + schema: AUTOSTART_JSON_SCHEMA, action, projects: [ { @@ -413,6 +422,7 @@ test("explicit autostart run intentionally invokes the scheduler and emits JSON" expect(JSON.parse(result.output)).toMatchObject({ ok: true, data: { + schema: AUTOSTART_JSON_SCHEMA, action: "run", waiting_for_engine: false, autostart_global: { paused: false }, From 131d218ab0263ff5c0fc9ddfcb120691be80f754 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:04:09 +0000 Subject: [PATCH 43/44] test(autostart): pin autostart_global in the legacy projects --json shape #835's success-shape test pins the legacy top-level keys of plain `projects --json`. This branch intentionally adds the machine-wide `autostart_global` object there (documented in always-on-projects), so add it to the pinned key set. Co-Authored-By: Claude Opus 5.5 --- tests/machine-output-success-shapes.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/machine-output-success-shapes.test.ts b/tests/machine-output-success-shapes.test.ts index 4c6b84cb0..0fd92599f 100644 --- a/tests/machine-output-success-shapes.test.ts +++ b/tests/machine-output-success-shapes.test.ts @@ -37,6 +37,8 @@ const CASES: readonly Case[] = [ { command: "projects", keys: [ + // Machine-wide always-on policy and login progress (always-on projects). + "autostart_global", "filter", "generated_at", "include_global", From f9f643fc6c918dedd4627f6354e843dd39f470a0 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:17:10 +0000 Subject: [PATCH 44/44] fix(autostart): require a manual start for a non-default lifecycle runner #811's opt-in `lifecycle.runner: "supervisor"` runs its companion binary even with no hooks or processes, so a runner-only project passed admission and the login child executed the supervisor preflight. Refuse any runner other than the default "mux" at admission, before a claim or dispatch. Co-Authored-By: Claude Opus 5.5 --- docs/always-on-projects.md | 4 ++-- src/lib/autostart-start-policy.ts | 6 ++++++ tests/autostart-start-policy.test.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md index f5e0835ef..87b80306a 100644 --- a/docs/always-on-projects.md +++ b/docs/always-on-projects.md @@ -123,8 +123,8 @@ beneath `/Applications`; that default layout is accepted by the exception above. The app bundle itself must still be owned by you or root and not writable by others. This unit supports Compose-only unattended starts. Projects declaring startup or -lifecycle hooks, host processes, shared helpers or dependency bootstrap require -manual `hack up`. The login child never executes those commands, even if the +lifecycle hooks, host processes, shared helpers, dependency bootstrap or a +non-default `lifecycle.runner` (such as `"supervisor"`) require manual `hack up`. The login child never executes those commands, even if the configuration changes after the daemon's first observation. Command/cloud env providers also require a manual start; built-in local encrypted env layers remain supported without keychain fallback. Managed values that change Compose selection, diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts index 11f81ae91..0ac995ae8 100644 --- a/src/lib/autostart-start-policy.ts +++ b/src/lib/autostart-start-policy.ts @@ -161,6 +161,12 @@ function hasHostCommands(raw: Record): boolean { if (!isRecord(raw.lifecycle)) { return present(raw.lifecycle); } + // A non-default runner (the lifecycle supervisor) executes its own companion + // binary even with no hooks or processes; only the default "mux" is unattended. + const runner = raw.lifecycle.runner; + if (runner !== undefined && runner !== "mux") { + return true; + } return [ raw.lifecycle.up, raw.lifecycle.down, diff --git a/tests/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts index f4c84141a..8ebc487fa 100644 --- a/tests/autostart-start-policy.test.ts +++ b/tests/autostart-start-policy.test.ts @@ -138,6 +138,18 @@ test("startup aliases, lifecycle phases, processes and shared helpers require ma } }); +test("a non-default lifecycle runner requires a manual start; the default mux runner does not", async () => { + await config({ name: "example", lifecycle: { runner: "supervisor" } }); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "host_commands_require_manual_start" + ); + // An unknown runner is refused too (here already as invalid configuration). + await config({ name: "example", lifecycle: { runner: "future-runner" } }); + expect(await autostartStartPolicyRefusal(opts)).not.toBeNull(); + await config({ name: "example", lifecycle: { runner: "mux" } }); + expect(await autostartStartPolicyRefusal(opts)).toBeNull(); +}); + test("legacy TOML startup is checked by the same canonical parser", async () => { await writeFile( join(opts.projectDir, "hack.toml"),