diff --git a/docs/always-on-projects.md b/docs/always-on-projects.md new file mode 100644 index 000000000..87b80306a --- /dev/null +++ b/docs/always-on-projects.md @@ -0,0 +1,457 @@ +# Always-on projects + +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 +hack daemon install +hack autostart add --project sample +hack autostart add --project 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. 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 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 +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 +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 +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`. 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. + +## At login + +![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. +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 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. +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. +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 +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 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. +The strict policy also applies to Linux user-private groups. Check `hack doctor` +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. +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, 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, +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 +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. + +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. + +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 non-secret bytes are republished on fresh `0644` inodes, +so container users can read them while other host users cannot write them. Routing +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, +preserve suspect files outside the active installation, secure their parent +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 +`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, declared `scale` or `deploy.replicas`, explicit Compose file/profile +overrides and unsupported composition +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 +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. 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 +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 +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 +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. +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 +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 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. Ordinary registration and +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 +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 +interrupted attempt; its outcome is unknown, so this slice requires manual +`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 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`. +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 +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. 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. +No system service or `sudo` is needed for Hack's login service. + +## JSON contract + +`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 +{ + "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": [], + "waiting_for_engine": false + }, + "service_ready": false + } +} +``` + +`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 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 +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 +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`, `checkout_untrusted`, +`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`. + +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 +`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. + +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. + +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. 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. + +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. +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. +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. +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. +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/docs/cli.md b/docs/cli.md index 6dc1419c5..3ecb4d65d 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..3ec23f77c --- /dev/null +++ b/docs/diagrams/autostart-login.mmd @@ -0,0 +1,44 @@ +--- +title: Autostart at login +--- +sequenceDiagram + autonumber + participant Login + participant Daemon + 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: Backoff wait, ten-minute bound + alt Engine unavailable + Daemon->>Registry: Waiting, no boot claims + Engine-->>Daemon: Later healthy observation + end + 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 new file mode 100644 index 000000000..84540a9ef --- /dev/null +++ b/docs/diagrams/autostart-login.svg @@ -0,0 +1,66 @@ +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 ade17b5f2..00dafb5fa 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8,6 +8,180 @@ 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 | +| `hack autostart run` | Intentionally run one non-interactive autostart pass | + +### 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) | +| `--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 | + +## `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 | +| --- | --- | +| `--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 | + +## `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 | +| --- | --- | +| `--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 | + +## `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 | +| --- | --- | +| `--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 | + +## `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` Inspect or manage project metadata @@ -188,6 +362,7 @@ hack up [services...] [options] | `--target ` | Execution target routing (auto routes to remote when project execution mode requires it) | | `--json` | Output JSON (machine-readable) | | `--progress` | With --json: stream ndjson progress lines before the result envelope (Compose backend) | +| `--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/machine-readable-output.md b/docs/reference/machine-readable-output.md index 0a5a2df7b..8e9a30a1c 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/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index 88f5ec35e..f563418d8 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 | [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. | | Standalone lifecycle supervisor: durable pre-launch identity, retained guard lock, project-supervisor loss, separately lost guard, verified stop and unknown stop | [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/COVERAGE.md) | `packages/lifecycle-supervisor` and its fake-caller contract tests; no CLI integration or default change in this slice | One command name, at most two starts, matching/foreign/unknown/reused-SID identities, arbitrary process exit and supervisor/guard loss; 426 states. Negative control removes the live-session retry fence and violates `NoDuplicate`. Native PID identity, filesystem synchronization, descriptors, output and macOS effects require implementation tests. | | Opt-in lifecycle controller: pending ownership before launch, stable native owner across adoption, cleanup epochs, publication after a lost reply and serialized add/stop | [lifecycle-controller](../../tests/models/tla/lifecycle-controller/COVERAGE.md) | `project-lifecycle-supervisor.ts`, `lifecycle-supervisor.ts`, common lifecycle state publication and controller/incremental regressions | One instance, one creator plus one adopter, one initial launch plus one incremental add; 21 states. Negative controls remove the cleanup epoch fence (`NoAdoptedStop`) and pending publication fence (`DurableBeforeLaunch`). Atomic lock regions and trustworthy native readback are assumptions; process identity, sockets, cancellation latency and OS effects remain implementation tests. | | Env migration publication, crash/resume and later cleanup | [env-migration](../../tests/models/tla/env-migration/COVERAGE.md): 74 states, two steps per phase, deferred Compose repair, separate publication and receipt, safe pre-removal cleanup refusal; unsafe controls fail `NoSameRunCleanup` and `FreshRunProof` | `env-migration.ts` binds immutable payloads and source checksums to a private snapshot, adopts interrupted renames, records completion, admits later explicit Compose repair without republishing env layers, and requires a new successful project run plus fresh snapshot/delivery/live-input proofs for cleanup. Real-file tests check preview parity, sentinels, backup damage, consent, tighter modes, refused cleanup retry and rollback; owned child processes receive SIGKILL at initial and later journal/publication boundaries | The model assumes atomic per-file publication/journal writes and cooperative locks; it does not qualify fsync durability, real snapshot provider behavior, partial cleanup/rollback, unsynchronized writers, actual runtime environment or macOS. Implementation and dependency integration gates remain separate. | diff --git a/docs/versions/v5/getting-started.md b/docs/versions/v5/getting-started.md index 94a3a928e..1b72dbaca 100644 --- a/docs/versions/v5/getting-started.md +++ b/docs/versions/v5/getting-started.md @@ -69,6 +69,24 @@ 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. 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. +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 have no URL. Sign in or load a page that reads data to check the app itself. @@ -118,6 +136,28 @@ routing services. See [Run and inspect](guides/run-project.md) for service selec 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. + +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. +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. +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. +Machine-wide autostart progress is exposed locally, not through the unsupported gateway. + Lifecycle host processes can opt into `lifecycle.runner: "supervisor"` when the Rust supervisor executable is installed alongside the CLI. The default stays `"mux"`. This mode has no terminal: use noninteractive commands and 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/scripts/lib/tla-runtime-models.ts b/scripts/lib/tla-runtime-models.ts index daf63899d..95d5c3e5e 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: "lifecycle-controller", module: "LifecycleController", diff --git a/src/backends/compose-unattended-preflight.ts b/src/backends/compose-unattended-preflight.ts new file mode 100644 index 000000000..7302180ab --- /dev/null +++ b/src/backends/compose-unattended-preflight.ts @@ -0,0 +1,419 @@ +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 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}}}'; +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: 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(); + if (remaining <= 0) { + return { exitCode: 124, stdout: "", stderr: "" }; + } + const result = await exec(args, { + cwd: opts.cwd, + env: opts.env, + stdin: "ignore", + timeoutMs: remaining, + maxStdoutBytes, + 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; +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; +} + +/** 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; + } + 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 8128cc0ef..8c7cbb27a 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, @@ -10,6 +11,12 @@ import { reportLifecycleProgress } from "../lib/lifecycle-progress.ts"; 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; @@ -38,6 +45,12 @@ 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[]; @@ -57,6 +70,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[]; @@ -151,10 +165,61 @@ export function buildComposeExecCommand(opts: { ]; } +async function guardStartupStorage( + opts: RuntimeUpOptions, + volumes: readonly UnattendedNamedVolume[], + binds: readonly UnattendedTrustedBind[], + timeoutMs: number +): Promise { + await opts.beforeStart?.(); + if (!opts.unattended || (volumes.length === 0 && binds.length === 0)) { + return 0; + } + 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 = { 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, @@ -174,18 +239,29 @@ 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 // start invocation then only starts the prepared containers and waits on // Compose dependency conditions; --no-build keeps it from rebuilding. const launch = async () => { + const inputs = await prepareUnattendedStartup( + opts, + composeArgs, + imageTimeoutMs + ); + if (inputs.exitCode !== 0) { + return inputs.exitCode; + } opts.onStartupPhase?.("images"); const prepared = await runPhase( [ - ...buildComposeArgs(opts), + ...composeArgs, "up", "--no-start", + ...(opts.unattended ? ["--no-build", "--pull", "never"] : []), ...(opts.forceRecreate ? ["--force-recreate"] : []), ...scope, ], @@ -195,9 +271,24 @@ 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( - [...buildComposeArgs(opts), "up", "-d", "--no-build", ...scope], + [ + ...composeArgs, + "up", + "-d", + "--no-build", + ...(opts.unattended ? ["--pull", "never"] : []), + ...scope, + ], startupTimeoutMs ); }; diff --git a/src/cli/json-contracts.ts b/src/cli/json-contracts.ts index 4204e5fa9..b8793aade 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/cli/spec.ts b/src/cli/spec.ts index 588f89ec3..0ea98db76 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -1,6 +1,7 @@ import pkg from "../../package.json"; import { activityCommand } from "../commands/activity.ts"; 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"; @@ -74,6 +75,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..3237c82f1 --- /dev/null +++ b/src/commands/autostart.ts @@ -0,0 +1,459 @@ +import { realpath } from "node:fs/promises"; +import { relative, resolve } from "node:path"; +import { + type CliContext, + CliUsageError, + type CommandHandlerFor, + defineCommand, + defineOption, + withHandler, +} from "../cli/command.ts"; +import { optBranch, optJson, optProject } from "../cli/options.ts"; +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 } 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"; +import { withTerminationAbort } from "../lib/project-shared-helpers.ts"; +import { + getDefaultAutostartGlobal, + type RegisteredProject, + readProjectsRegistry, + removeProjectAutostart, + selectRegisteredProjectByName, + setAutostartGlobalPaused, + setProjectAutostart, + setProjectAutostartPaused, +} 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", + summary: "Start this registered project at login on this machine", + group: "Project", + options: [optBranch, optProject, optJson], + positionals: projectPositionals, + subcommands: [], +} as const); +const removeSpec = defineCommand({ + name: "remove", + summary: "Remove this project's machine autostart marker", + group: "Project", + options: [optProject, optJson], + positionals: projectPositionals, + subcommands: [], +} as const); +const pauseSpec = defineCommand({ + name: "pause", + summary: "Pause one marked project, or all marked projects", + group: "Project", + options: [optProject, optJson], + positionals: projectPositionals, + subcommands: [], +} as const); +const resumeSpec = defineCommand({ + name: "resume", + summary: "Resume one marked project, or all marked projects", + group: "Project", + options: [optProject, optJson], + 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", + group: "Project", + options: [optJson], + positionals: [], + 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: machineSuccess({ + schema: AUTOSTART_JSON_SCHEMA, + 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; + 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: selectedProjectName({ + positional: args.positionals.project, + option: args.options.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." + ); + } + // 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" + ), + }); + 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: 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 = + selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }) === undefined + ? null + : 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 = + selectedProjectName({ + positional: args.positionals.project, + option: args.options.project, + }) === undefined + ? null + : 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 }) => { + 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)); + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); + if (args.options.json) { + 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) { + 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", + projectAutostartFromGlobal({ project, global }).state, + project.autostart?.lastResult?.status ?? "not attempted", + project.autostart?.lastResult?.reason ?? "", + ]), + }); + 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." + ); + } + return await withTerminationAbort(async (signal) => { + const result = await runAutostartAtLogin({ + signal, + engineWaitMs, + runtime: { + home: resolveUserHomeDir(), + hackHome: resolveGlobalHackDir(), + serviceOrigin: false, + platform: process.platform, + }, + }); + if (signal.aborted) { + return 1; + } + const registry = await readProjectsRegistry(); + if (args.options.json) { + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); + emitCliResult({ + result: machineSuccess({ + schema: AUTOSTART_JSON_SCHEMA, + 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", + group: "Project", + options: [], + positionals: [], + subcommands: [ + withHandler(addSpec, add), + withHandler(removeSpec, remove), + 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/doctor.ts b/src/commands/doctor.ts index ea8d0789c..ca52f5359 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"; @@ -65,6 +66,7 @@ import { resolveSystemdUnitPath, SYSTEMD_READ_ONLY_ENV, } from "../daemon/systemd.ts"; +import { autostartTrustWarnings } from "../lib/autostart-trust-diagnostics.ts"; import { type CaTrustReport, inspectCaTrust, @@ -915,6 +917,11 @@ async function runDoctorAudit( // 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 @@ -2013,6 +2020,46 @@ 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.", + }; + } + 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; 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; + 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/global-autostart.ts b/src/commands/global-autostart.ts new file mode 100644 index 000000000..c67b53b2b --- /dev/null +++ b/src/commands/global-autostart.ts @@ -0,0 +1,126 @@ +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"; +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; + } + assertAutostartDockerConfigTrusted({ home: homedir() }); + 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 and move unexpected schema/dashboard files aside, then 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", "--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; +}): 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"})`, + }); + } +} + +/** 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); +} + +/** 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 68d07801a..a45fb14e1 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -144,10 +144,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, @@ -183,6 +180,15 @@ import { planGlobalTrust, type TrustPlanDeps, } from "./global-admin-plan.ts"; +import { + assertGlobalBackendStartAllowed, + assertGlobalLoginRoutingImages, + globalDnsRecoveryAllowed, + globalLoggingUpArguments, + globalRoutingUpArguments, + reconcileGlobalRoutingBridge, + secureGlobalLoginRouting, +} from "./global-autostart.ts"; import { executeMacPrivilegedCommand, macAuthorizationArgv, @@ -872,12 +878,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( @@ -1848,12 +1858,18 @@ export async function globalUp(): Promise { return slimExit; } - await ensureDockerRunning(); - if (isMac()) { - await ensureMacDnsmasqRunning(); - } const paths = getGlobalPaths(); + const login = process.env.HACK_AUTOSTART_LOGIN === "1"; + await secureGlobalLoginRouting({ root: paths.root, login }); + await ensureDockerRunning({ login }); + await assertGlobalLoginRoutingImages({ + composeFile: paths.caddyCompose, + login, + }); + if (isMac() && globalDnsRecoveryAllowed({ login })) { + await ensureMacDnsmasqRunning(); + } if (!(await pathExists(paths.caddyCompose))) { logger.error({ message: `Missing ${paths.caddyCompose}. Run: hack global install`, @@ -1919,15 +1935,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) { @@ -1954,32 +1962,20 @@ 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", - ], - { 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/src/commands/project-autostart-intent.ts b/src/commands/project-autostart-intent.ts new file mode 100644 index 000000000..ca6400e7d --- /dev/null +++ b/src/commands/project-autostart-intent.ts @@ -0,0 +1,443 @@ +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"; +import { + 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 { + 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 { assertEnvMigrationFinished } from "../lib/env-migration.ts"; +import { pathExists } from "../lib/fs.ts"; +import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; +import { + findProjectContextAtRoot, + type ProjectContext, + readProjectConfig, + resolveWorktreeAutoBranch, + 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 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; + readonly setProjectAutostartPaused?: typeof setProjectAutostartPaused; + } +): Promise { + const registry = await (deps?.readProjectsRegistry ?? readProjectsRegistry)(); + const context = await discoverAutostartCommandContext({ ...opts, registry }); + if (!context) { + return undefined; + } + const { project, registration } = 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) { + // A login pass may be waiting for this same engine. Stop intent cannot + // 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, + pause: deps?.setProjectAutostartPaused ?? setProjectAutostartPaused, + }); + throw new HackCliError({ + code: "E_DOCKER_UNAVAILABLE", + message: `Cannot inspect runtime ownership before down: ${runtime.error}${paused ? " Automatic startup for this project is paused; run hack up to resume it." : ""}`, + }); + } + return { + branch: reconcileImplicitDownBranch({ + baseProjectName: await resolveComposeProjectName({ project, cfg }), + branch: effective.branch, + projectDir: project.projectDir, + runtime: runtime.runtime, + writeNotice: opts.writeNotice ?? (() => undefined), + }), + }; +} + +/** + * 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 pause: typeof setProjectAutostartPaused; +}): Promise { + const marker = opts.registration?.autostart; + if (!(opts.registration && marker) || marker.paused) { + 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 } +): Promise< + | { + readonly project: ProjectContext; + readonly registration: RegisteredProject | undefined; + } + | 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 = + 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; +}): 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) + ); + 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; +} + +/** 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; + readonly branch?: string; + 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; + const registry = await readProjectsRegistry( + login ? { requireAutostartTrust: true } : undefined + ); + if (login) { + await assertLoginCheckoutBeforeDiscovery({ + projectId: process.env.HACK_AUTOSTART_PROJECT_ID, + projects: registry.projects, + }); + } + const context = await discoverAutostartCommandContext({ ...opts, registry }); + if (!context) { + return await opts.run(); + } + const { project, registration } = context; + const cfg = await readProjectConfig(project); + 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; + if (login) { + assertLoginProjectIdentity({ + projectId, + projects: registry.projects, + root: project.projectRoot, + }); + } + 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(); + }, + }); +} + +/** Check the trusted registry target before even reading repository-controlled inputs. */ +async function assertLoginCheckoutBeforeDiscovery(opts: { + readonly projectId?: string; + readonly projects: readonly RegisteredProject[]; +}): Promise { + 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); + await assertAutostartStartPolicy(project); + assertAutostartDockerConfigTrusted({ home: homedir() }); +} + +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, + 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-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 746ff9cd2..89bc439fd 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1,3 +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 { assertEnvMigrationFinished, @@ -28,6 +30,7 @@ export function wrapLifecyclePersistentCommand( return wrapSharedLifecyclePersistentCommand(opts); } +import { realpath } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import { autocompleteMultiselect, @@ -125,7 +128,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, @@ -340,6 +346,7 @@ import { finishImplicitLegacyEnvMigration, HACK_DIR_GITIGNORE_READ_ONLY_WARNING, hasUnmigratedLegacyDotenv, + ProjectEnvKeyMissingError, projectEnvConfigExists, resolveProjectEnvConfig, selectProjectEnvValuesForExecutionTarget, @@ -379,6 +386,7 @@ import { } from "../lib/project-shared-helpers.ts"; import { readProjectsRegistry, + setProjectAutostartPaused, upsertProjectRegistration, } from "../lib/projects-registry.ts"; import { @@ -400,6 +408,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"; @@ -434,6 +443,15 @@ import { withActivity } from "./lifecycle-activity.ts"; import { noteNativeBackendBeta } from "./native-beta-notice.ts"; import { displayNativePs } from "./native-ps-display.ts"; import { answerNativeAlreadyRunning } from "./native-up-already-running.ts"; +import { + assertAutostartEnvironment, + assertLoginAutostartCurrent, + findAutostartRegistration, + resolveFinalAutostartDownTarget, + updateNativeAutostartIntent, + withAutostartCommandLock, +} from "./project-autostart-intent.ts"; +import { enableAutostartForUp } from "./project-autostart-opt-in.ts"; import { prepareDisposableCachePrune, reconcileImplicitDownBranch, @@ -594,6 +612,12 @@ const upOptions = [ optTarget, optJson, optProgress, + 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, @@ -746,7 +770,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 @@ -952,6 +979,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({ @@ -1211,7 +1242,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 }); } @@ -1993,6 +2037,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}` }); @@ -2168,6 +2213,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({ runner: resolveLifecycleRunner({ runner: opts.cfg.lifecycle?.runner }), sharedHelpersConfigured: sharedHelperConfigs.length > 0, @@ -2988,7 +3040,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; } } @@ -5951,6 +6003,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, @@ -6471,7 +6527,135 @@ async function restartNativeImageProject({ }); } -async function handleUp({ +/** 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 resolvedDownTarget?: { readonly branch: string | null }; + 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 = 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, + projectOpt: opts.args.options.project, + touchRegistration: false, + }); + if (login) { + await assertAutostartEnvironment({ project }); + 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 = opts.resolvedDownTarget + ? opts.resolvedDownTarget.branch + : resolvedBranch.branch; + if (opts.action === "up" && opts.args.options.always) { + await enableAutostartForUp({ + project, + startDir: resolveStartDir(opts.ctx, opts.args.options.path), + selectorName: selector?.name, + branch, + registry, + }); + 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( + 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({ + action: "up", + 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, }: { @@ -6941,7 +7125,14 @@ async function runUpCommand({ assertCurrent: assertHelpersCurrent, up: (beforeStart) => 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?.(); if (!detach) { // Compose owns foreground shutdown. Lifecycle cleanup runs after @@ -7258,7 +7449,57 @@ 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. + 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, + resolvedDownTarget, + run: () => handleDownUnlocked(pinned), + }); +} + +async function handleDownUnlocked({ ctx, args, deferFinalization, @@ -7463,11 +7704,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( @@ -7531,7 +7774,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) { @@ -7552,9 +7797,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 = @@ -8570,7 +8815,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 28a58bc4a..ecb4e4177 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -19,12 +19,19 @@ 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, 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 { MACHINE_SCHEMAS, machineSuccess } from "../lib/machine-output.ts"; @@ -53,11 +60,13 @@ import { import type { ProjectView } from "../lib/project-views.ts"; import { buildProjectViews, + serializeProjectAutostart, serializeProjectSummary, serializeProjectView, } from "../lib/project-views.ts"; import { findDeadProjectRegistrations, + getDefaultAutostartGlobal, type RegisteredProject, readProjectsRegistry, removeProjectsById, @@ -523,6 +532,8 @@ async function runNativeStatus(opts: { readonly json: boolean; 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 }); @@ -534,6 +545,37 @@ 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, + }); + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await (opts.serviceReady ?? readAutostartServiceReady)() + ); + return { + autostart: projectAutostartFromGlobal({ + project: registration ?? undefined, + global, + }), + autostart_global: global, + }; + }, }); if (authored !== null) { return authored; @@ -568,7 +610,39 @@ 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 global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await (opts.serviceReady ?? readAutostartServiceReady)() + ); + const autostart = projectAutostartFromGlobal({ + project: registration, + global, + }); + if (opts.json) { + emitCliResult({ + result: okResult({ + data: { ...result, autostart, autostart_global: global }, + }), + }); + } 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; } @@ -634,8 +708,13 @@ async function runProjects(opts: { buildMetaByProjectName({ views }) ) : new Map(); + const global = serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ); if (opts.json) { outputProjectsJson({ + autostartGlobal: global, observe: opts.observe === true, observeRuntime: opts.observeRuntime === true, summary: opts.summary === true, @@ -668,9 +747,26 @@ 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: ["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; @@ -679,9 +775,23 @@ async function runProjects(opts: { runningCount, runtimeOk: runtime.ok, }); + const autostartState = serializeProjectAutostart(p.autostart, { + globallyPaused: global.paused, + starting: + (global.login_run.in_progress || + global.login_run.waiting_for_engine) && + p.projectId !== undefined && + global.login_run.pending.includes(p.projectId), + }).state; 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 ?? "", @@ -775,7 +885,17 @@ 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, + global: serializeAutostartGlobal( + registry.autostartGlobal ?? getDefaultAutostartGlobal(), + await readAutostartServiceReady() + ), + }); writeProfiledProjects({ payload, profiler, @@ -789,6 +909,39 @@ 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[]; + readonly global?: AutostartGlobalView; +}): Record { + const markers = new Map( + 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: projectAutostartFromGlobal({ + project: + typeof project.project_id === "string" + ? markers.get(project.project_id) + : undefined, + global, + }), + } + : project + ) + : opts.cached.projects, + }; +} + type RuntimeRecoveryMeta = { readonly resetCount: number; readonly lastResetSummary: string | null; @@ -804,6 +957,7 @@ type RuntimeRecoveryNotice = { }; function outputProjectsJson(opts: { + readonly autostartGlobal: AutostartGlobalView; readonly observe: boolean; readonly observeRuntime: boolean; readonly summary: boolean; @@ -823,6 +977,7 @@ function outputProjectsJson(opts: { }): void { const runtimeMeta = formatRuntimeMeta({ runtime: opts.runtime }); const payload = { + autostart_global: opts.autostartGlobal, ...(opts.observe ? { observe: true, runtime_requested: opts.observeRuntime } : {}), @@ -852,6 +1007,14 @@ function outputProjectsJson(opts: { ...(opts.summary ? serializeProjectSummary(view) : serializeProjectView(view)), + autostart: serializeProjectAutostart(view.autostart, { + globallyPaused: opts.autostartGlobal.paused, + starting: + (opts.autostartGlobal.login_run.in_progress || + opts.autostartGlobal.login_run.waiting_for_engine) && + view.projectId !== undefined && + opts.autostartGlobal.login_run.pending.includes(view.projectId), + }), ...(opts.includeMeta ? { meta: opts.metaByName.get(view.name) ?? null } : {}), @@ -1581,6 +1744,7 @@ function readRepairOutcomeField(opts: { } export const __testOnlyProjectsCommand = { + overlayAutostartPolicy, buildRuntimeRecoveryNotice, runNativeStatus, filterPruneRegistryProjects, 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 new file mode 100644 index 000000000..4e7468072 --- /dev/null +++ b/src/daemon/autostart.ts @@ -0,0 +1,1272 @@ +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"; +import { YAML } from "bun"; +import { resolveNativeRuntimeSelection } from "../backends/native-runtime-client.ts"; +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, + 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 { 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"; +import { findProjectContextAtRoot, readProjectConfig } from "../lib/project.ts"; +import { + beginAutostartLoginRun, + claimProjectAutostart, + finishAutostartLoginRun, + type RegisteredProject, + readAutostartGlobal, + readProjectsRegistry, + recordAutostartLoginOutcome, + recordProjectAutostartResult, + setAutostartLoginWaitingForEngine, +} from "../lib/projects-registry.ts"; +import { checkServiceExecutableTrusted } from "./systemd.ts"; + +export type AutostartResult = { + readonly status: "started" | "skipped" | "failed" | "already_running"; + readonly reason: string | null; + readonly at: string; +}; + +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. */ +export type AutostartDependencies = { + readonly bootId: () => Promise; + readonly startupRefusal?: () => + | AutostartStartupRefusal + | Promise; + readonly projectRefusal?: ( + project: RegisteredProject + ) => Promise; + readonly now: () => number; + readonly sleep: (ms: number, signal?: AbortSignal) => Promise; + readonly engineReady: (opts?: { + readonly timeoutMs: number; + readonly signal?: AbortSignal; + }) => Promise; + readonly routingInstalled: () => Promise; + readonly startRouting: (signal?: AbortSignal) => Promise; + readonly inspect: ( + project: RegisteredProject, + signal?: AbortSignal + ) => Promise<"running" | "stopped" | "unknown" | "untrusted">; + readonly start: ( + project: RegisteredProject, + signal?: AbortSignal + ) => Promise; +}; + +const MAC_BOOT_TIME_PATTERN = /sec = (\d+), usec = (\d+)/; + +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 + * 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; + readonly runtime?: AutostartRuntimeOptions; + readonly engineWaitMs?: number; + } = {} +): Promise { + const runtime = opts.runtime; + const deps = + opts.dependencies ?? + createAutostartProductionDependencies(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({ + runId: prior.loginRun.runId, + registryPath: opts.registryPath, + }); + } + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + requireAutostartTrust: true, + }); + 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 + .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 { 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 { waitingForEngine: false }; + } + let waitingForEngine = false; + try { + if (machineBootId === null || startupRefusal) { + await recordUnclaimedAutostartRefusal({ + pending, + runId: loginRun.runId, + registryPath: opts.registryPath, + deps, + reason: startupRefusal ?? "boot_identity_unavailable", + signal: opts.signal, + }); + 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, + signal: opts.signal, + registryPath: opts.registryPath, + engineWaitMs: opts.engineWaitMs ?? opts.runtime?.engineWaitMs, + runId: loginRun.runId, + startupRefusal, + }); + 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, + pending: eligible, + deps, + bootId, + runId: loginRun.runId, + refusal, + }); + } finally { + await finishAutostartLoginRun({ + runId: loginRun.runId, + registryPath: opts.registryPath, + waitingForEngine, + }); + } + 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 || isAutostartManualStartReason(opts.reason), + 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; + 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 || + (await readAutostartGlobal({ registryPath: opts.registryPath })).paused + ) { + 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({ + ...opts, + project, + onDispatched: dispatched, + }); + 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 startupRefusal: AutostartStartupRefusal; + readonly runId: string; + readonly engineWaitMs?: number; + readonly registryPath?: string; + readonly signal?: AbortSignal; +}): Promise { + const refusal = opts.startupRefusal; + if (refusal) { + return refusal; + } + if (!opts.machineBootId) { + return "boot_identity_unavailable"; + } + const { deps, signal } = opts; + 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"; + } + 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(delay, deadline - deps.now()), signal); + delay = Math.min(delay * 2, ENGINE_POLL_MAX_MS); + } + if (!ready) { + return "engine_timeout"; + } + if ( + signal?.aborted || + (await readAutostartGlobal({ registryPath: opts.registryPath })).paused + ) { + return "global_paused"; + } + await setAutostartLoginWaitingForEngine({ + runId: opts.runId, + waitingForEngine: false, + registryPath: opts.registryPath, + }); + 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; +} + +type ClaimedAutostartOptions = { + readonly project: RegisteredProject; + readonly bootId: string; + readonly runId: string; + readonly refusal: string | null; + readonly onDispatched: () => void; + readonly deps: AutostartDependencies; + readonly signal?: AbortSignal; + readonly registryPath?: string; +}; + +async function startClaimedProject( + opts: ClaimedAutostartOptions +): Promise { + let result: StartResult = { status: "skipped", reason: opts.refusal }; + try { + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + requireAutostartTrust: true, + }); + const current = registry.projects.find( + (project) => project.id === opts.project.id + ); + if ( + !current?.autostart || + current.autostart.paused || + registry.autostartGlobal?.paused || + opts.signal?.aborted + ) { + result = { + status: "skipped", + reason: opts.signal?.aborted ? "daemon_stopped" : "paused", + }; + } else if (!opts.refusal) { + result = await inspectAndDispatchAutostart({ ...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() }, + releaseUnstartedClaim: + result.status === "skipped" && + isAutostartManualStartReason(result.reason), + expectedBranch: opts.project.autostart?.branch, + registryPath: opts.registryPath, + }); + await recordAutostartLoginOutcome({ + runId: opts.runId, + id: opts.project.id, + succeeded: + result.status === "started" || result.status === "already_running", + cancelled: + result.reason !== null && + (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; + readonly deps: AutostartDependencies; + readonly signal?: AbortSignal; + readonly registryPath?: string; +}): Promise { + 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", + 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_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) && + (detail.reason === "pending_env_migration" || + detail.reason === "native_unsupported" || + detail.reason === "keychain_unavailable") + ) { + return { status: "skipped", reason: detail.reason }; + } + if ( + code === "E_PERMISSION" && + isRecord(detail) && + (isAutostartManualStartReason(detail.reason) || + detail.reason === "checkout_untrusted" || + detail.reason === "executable_path_untrusted" || + detail.reason === "docker_config_untrusted") + ) { + return { status: "skipped", reason: detail.reason }; + } + if (code === "E_PROJECT_NOT_FOUND") { + return { status: "skipped", reason: "project_unavailable" }; + } + 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 } + : { + 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"; + } +} + +/** 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(); +} + +export function createAutostartProductionDependencies( + runtime: AutostartRuntimeOptions, + checks?: { + readonly executablePath?: typeof assertAutostartExecutablePathTrusted; + } +): AutostartDependencies { + const env = autostartServiceEnvironment(runtime); + const invocation = [ + process.execPath, + ...(Bun.main.startsWith("/$bunfs/") + ? [] + : [ + "--no-env-file", + fileURLToPath(new URL("../../index.ts", import.meta.url)), + ]), + ]; + return { + projectRefusal: (project) => + autostartStartPolicyRefusal({ + ...project, + globalConfigPath: resolve(runtime.hackHome, GLOBAL_CONFIG_FILENAME), + }), + startupRefusal: async () => { + try { + await (checks?.executablePath ?? assertAutostartExecutablePathTrusted)({ + path: env.PATH ?? "", + }); + } catch { + return "executable_path_untrusted"; + } + try { + assertAutostartDockerConfigTrusted({ home: runtime.home, env }); + } catch { + return "docker_config_untrusted"; + } + return autostartRuntimeRefusal(env); + }, + 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(env), + engineReady: async (probe) => + ( + await runAutostartSubprocess( + ["docker", "info", "--format", "{{.ID}}"], + { + timeoutMs: probe?.timeoutMs ?? 2000, + terminationGraceMs: 0, + signal: probe?.signal, + env, + } + ) + ).exitCode === 0, + routingInstalled: () => autostartRoutingInstalled(runtime.hackHome), + startRouting: async (signal) => + ( + await runAutostartSubprocess( + [...invocation, "global", "up", "--no-interactive"], + { + timeoutMs: 60_000, + signal, + env, + routing: true, + } + ) + ).exitCode === 0, + inspect: (project, signal) => inspectComposeProject(project, signal, env), + start: async (project, signal) => { + const args = buildAutostartProjectInvocation({ + invocation, + project, + serviceOrigin: runtime.serviceOrigin, + platform: runtime.platform, + }); + return classifyAutostartExit( + 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, + env: Readonly> = {} +): Promise<"running" | "stopped" | "unknown" | "untrusted"> { + try { + assertAutostartCheckoutTrusted({ + repoRoot: project.repoRoot, + projectDir: project.projectDir, + }); + } 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, + }); + if (!context) { + return "unknown"; + } + const cfg = await readProjectConfig(context); + if (cfg.parseError) { + return "unknown"; + } + 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}` + : 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, env } + ); + 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", + '{{index .Config.Labels "com.docker.compose.service"}}|{{.State.Status}}|{{.State.ExitCode}}', + ...ids, + ], + { timeoutMs: 5000, signal, env } + ); + if (inspected.exitCode !== 0) { + return "unknown"; + } + return classifyAutostartContainers( + inspected.stdout, + model.completedServices, + model.requiredServices + ); +} + +/** + * 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, + 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) { + return "unknown"; + } + if ( + status !== "running" && + !( + status === "exited" && + exitCode === "0" && + completedServices.has(service) + ) + ) { + return "stopped"; + } + observed.add(service); + running ||= status === "running"; + } + if ([...requiredServices].some((service) => !observed.has(service))) { + return "stopped"; + } + return running ? "running" : "stopped"; +} + +type AutostartComposeModel = { + readonly requiredServices: ReadonlySet; + readonly completedServices: ReadonlySet; +}; + +async function readAutostartComposeModel( + composeFile: string +): Promise { + const text = await readTextFile(composeFile); + if (text === null) { + return null; + } + try { + return autostartComposeModel(YAML.parse(text)); + } catch { + 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 || + service.scale !== undefined || + (isRecord(service.deploy) && service.deploy.replicas !== 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. */ +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( + env: Readonly> +): 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 sysctl = "/usr/sbin/sysctl"; + if ( + (await checkServiceExecutableTrusted({ path: sysctl, uid: 0 })) !== null + ) { + return null; + } + const result = await runAutostartSubprocess( + [sysctl, "-n", "kern.boottime"], + { + timeoutMs: 2000, + env, + } + ); + const match = result.stdout.match(MAC_BOOT_TIME_PATTERN); + return result.exitCode === 0 && match ? `${match[1]}:${match[2]}` : null; + } + 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: 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[], + opts: { + readonly timeoutMs: number; + 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<{ + 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: { + ...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 } : {}), + ...(opts.routing ? { COMPOSE_ENV_FILES: "/dev/null" } : {}), + }, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + 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; + stopping ??= terminateAutostartGroup({ + pid: proc.pid, + // 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); + 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; + await stopping; + return { + exitCode: reason ? 124 : exitCode, + stdout: reason ? "" : stdout, + ...(reason ? { reason } : {}), + }; + } finally { + reader.releaseLock(); + clearTimeout(timer); + 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/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/launchd.ts b/src/daemon/launchd.ts index 336e8114b..b514a7a2f 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -193,6 +193,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/runtime-cache.ts b/src/daemon/runtime-cache.ts index 3720470e2..f0c08a621 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, @@ -360,11 +373,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 } : {}), })) ); @@ -383,6 +410,7 @@ export function createRuntimeCache(opts: { } : {}), generated_at: new Date().toISOString(), + autostart_global: global, filter, include_global: includeGlobal, include_unregistered: includeUnregistered, diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 1504efdf0..1e36b74e5 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,9 +23,13 @@ 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"; import { evaluateGatewayRouteAccess, validateControlPlaneRouteTarget, @@ -29,7 +39,12 @@ import { shouldRefreshForDockerEvent, startDockerEventWatcher, } from "./docker-events.ts"; +import { gatewayProjectsPayload } from "./gateway-projects-payload.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"; @@ -113,8 +128,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; @@ -185,7 +240,7 @@ export async function runDaemon({ } const refreshIntervalMs = 30_000; - setInterval(() => { + const refreshInterval = setInterval(() => { refreshScheduler.request({ reason: "interval", urgency: "immediate" }); }, refreshIntervalMs); @@ -339,6 +394,8 @@ export async function runDaemon({ const shutdown = async ({ reason }: { readonly reason: string }) => { logger.warn({ message: `Shutting down hackd (${reason})` }); + clearInterval(refreshInterval); + await loginAutostart.stop(); refreshScheduler.stop(); watcher.stop(); await daemonProxy?.close(); @@ -359,6 +416,7 @@ export async function runDaemon({ logger.info({ message: `hackd started (pid ${process.pid}, version ${packageJson.version})`, }); + loginAutostart.start(); } async function handleRequest({ @@ -692,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/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 33da37e10..bdd1bfa19 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -51,9 +51,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. @@ -520,12 +521,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(" ")}`, @@ -2007,25 +2010,36 @@ 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; + /** 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) { 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, + acceptDarwinAdminDirectories: admin, }); if ("error" in walk) { return `the hackd.service PATH directory ${dir} is not safe: ${walk.error}`; } - const writable = lettingOthersCreate(walk.existing); + recordInspectedPaths(directoryPaths, opts.inspectedPaths); + 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}`; } @@ -2036,15 +2050,29 @@ export async function checkServicePathDirectories(opts: { dir: walk.existing, uid, checked, + inspectedPaths: opts.inspectedPaths, + acceptDarwinAdminDirectories: admin, }); 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 @@ -2059,6 +2087,8 @@ async function checkPathDirectoryPrograms(opts: { readonly dir: string; readonly uid: number; readonly checked: Set; + readonly inspectedPaths?: Set; + readonly acceptDarwinAdminDirectories?: boolean; }): Promise { let entries: string[]; let systemDir: boolean; @@ -2083,11 +2113,17 @@ 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; } + opts.inspectedPaths?.add(path); } return null; } @@ -2103,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, @@ -2110,6 +2147,7 @@ async function checkPathLink(opts: { hackHome: null, allowMissing: true, checked: opts.checked, + acceptDarwinAdminDirectories: opts.acceptDarwinAdminDirectories, }); if ("error" in walk) { return walk.error; @@ -2117,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-checkout-trust.ts b/src/lib/autostart-checkout-trust.ts new file mode 100644 index 000000000..f5716a2db --- /dev/null +++ b/src/lib/autostart-checkout-trust.ts @@ -0,0 +1,124 @@ +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"; + +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 + * 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(); + } + } + // 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(); + } + } + 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); + assertAutostartPathAclsTrustedSync(checked); + } catch { + throw refusal(); + } +} 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-docker-trust.ts b/src/lib/autostart-docker-trust.ts new file mode 100644 index 000000000..07c491ebc --- /dev/null +++ b/src/lib/autostart-docker-trust.ts @@ -0,0 +1,201 @@ +import { createHash } from "node:crypto"; +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 { isDarwinAdminDirectory, walkReplaceableChain } from "./path-trust.ts"; + +const LOGIN_DOCKER_TRANSPORT = /^unix:\/\//; + +/** 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 { + 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, + 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 && + !isDarwinAdminDirectory(existing) + ) { + throw new Error("Selection directory permits new entries"); + } + 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); + 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 env = opts.env ?? process.env; + let currentContext = "default"; + const configPath = join(root, "config.json"); + assertAutostartPathAclsTrustedSync(checked); + 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.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, uid); + } + // 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({ + 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" }, + }); + } +} + +/** 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, + acceptDarwinAdminDirectories: 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 && + !isDarwinAdminDirectory(info) + ) { + throw new Error("Replaceable socket parent"); + } + } + assertAutostartPathAclsTrustedSync(checked); +} diff --git a/src/lib/autostart-executable-trust.ts b/src/lib/autostart-executable-trust.ts new file mode 100644 index 000000000..2175a8120 --- /dev/null +++ b/src/lib/autostart-executable-trust.ts @@ -0,0 +1,40 @@ +import { checkServicePathDirectories } from "../daemon/systemd.ts"; +import { + type AutostartAclDependencies, + assertAutostartPathAclsTrusted, +} from "./autostart-path-acl.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 }, + deps?: AutostartAclDependencies +): Promise { + try { + const inspectedPaths = new Set(); + if ( + (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); + 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-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 new file mode 100644 index 000000000..5b1f58ab5 --- /dev/null +++ b/src/lib/autostart-project-lock.ts @@ -0,0 +1,125 @@ +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 { assertAutostartFileAclsTrusted } from "./autostart-path-acl.ts"; +import { assertAutostartStartPolicy } from "./autostart-start-policy.ts"; +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.", + }); + } + 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({ + 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 selected = projects.length === 1 ? projects[0] : undefined; + const marker = selected?.autostart; + 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 }, + }); + } + if (opts.expectedRoot !== undefined && selected) { + if (process.env.HACK_AUTOSTART_LOGIN === "1") { + assertAutostartDockerConfigTrusted({ home: homedir() }); + } + assertAutostartCheckoutTrusted({ + 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..fc591dda0 --- /dev/null +++ b/src/lib/autostart-refusal.ts @@ -0,0 +1,45 @@ +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", + "adopted_project_requires_manual_start", +]); + +/** 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); +} + +/** 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; +}): 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-routing-assets.ts b/src/lib/autostart-routing-assets.ts new file mode 100644 index 000000000..08ec630e1 --- /dev/null +++ b/src/lib/autostart-routing-assets.ts @@ -0,0 +1,345 @@ +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 { assertAutostartPathAclsTrusted } from "./autostart-path-acl.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; + /** 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 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. + */ +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 })); + } + 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, + beforePublish: opts.beforePublish, + }); + } + } catch { + throw new HackCliError({ + code: "E_PERMISSION", + message: + "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" }, + }); + } +} + +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); + const checked = new Set(); + if ( + 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 [ + "", + ...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(); + await assertAutostartPathAclsTrusted(new Set([opts.asset.path])); + 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; + readonly beforePublish?: AutostartRoutingAssetsOptions["beforePublish"]; +}): 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"); + } + // 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"); + // 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); + await assertAutostartPathAclsTrusted(new Set([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"); + } + await rename(staging, asset.path); + } finally { + await file.close(); + await unlink(staging).catch(() => { + /* A successful publication moved this owned staging file. */ + }); + } +} diff --git a/src/lib/autostart-start-policy.ts b/src/lib/autostart-start-policy.ts new file mode 100644 index 000000000..0ac995ae8 --- /dev/null +++ b/src/lib/autostart-start-policy.ts @@ -0,0 +1,489 @@ +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 { 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, + type ProjectDirName, + parseProjectConfigRecord, +} from "./project.ts"; +import { + isExcludedLegacyDotenvName, + isMaterializedBaseDotenv, +} from "./project-env-config.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" + | "adopted_project_requires_manual_start"; +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; readonly checked: Set }; + +/** 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 { + 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) + ) { + 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, 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"; + } + 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; + } + const envReason = await envPolicyReason({ ...opts, budget }); + await assertAutostartPathAclsTrusted(budget.checked); + return envReason; + } 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); + } + // 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, + 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 || + hasUnresolvedComposeMerge(compose) || + !hasOnlyEngineManagedMounts(compose) + ) { + return "compose_inputs_require_manual_start"; + } + for (const service of Object.values(compose.services)) { + const reason = servicePolicyReason(service); + if (reason) { + return reason; + } + } + 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 { + 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"; + } + // `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"; + } + const reason = environmentInputReason({ name, text }); + if (reason) { + return reason; + } + } + } + return null; +} + +function isEnvironmentInput(name: string): boolean { + return ( + ((name === ".env" || 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)) + ); +} + +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 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 }; +} + +/** Current inode checks cannot authenticate permission history; formerly writable inputs require reviewed replacement. */ +async function readPolicyText( + path: string, + budget: Budget +): Promise { + const uid = process.getuid?.(); + if ( + uid === undefined || + checkReplaceableChain({ + path: dirname(path), + uid, + hackHome: null, + checked: budget.checked, + }) !== 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; + budget.checked.add(path); + 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 new file mode 100644 index 000000000..7acd2bae0 --- /dev/null +++ b/src/lib/autostart-trust-diagnostics.ts @@ -0,0 +1,44 @@ +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. */ +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); + const reason = await autostartStartPolicyRefusal(project); + if (reason) { + warnings.add(reason); + } + } 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/src/lib/autostart-trust-mounts.ts b/src/lib/autostart-trust-mounts.ts new file mode 100644 index 000000000..a5e34633b --- /dev/null +++ b/src/lib/autostart-trust-mounts.ts @@ -0,0 +1,149 @@ +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 { assertAutostartPathAclsTrusted } from "./autostart-path-acl.ts"; +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 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"); + } + 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"); + } + checked.add(path); + await assertAutostartPathAclsTrusted(checked); + 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/src/lib/autostart-view.ts b/src/lib/autostart-view.ts new file mode 100644 index 000000000..0da771ae3 --- /dev/null +++ b/src/lib/autostart-view.ts @@ -0,0 +1,154 @@ +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 waiting_for_engine: 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.global.login_run.waiting_for_engine) && + 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, + waiting_for_engine: state.loginRun.waitingForEngine, + 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/child-environment.ts b/src/lib/child-environment.ts index 6db8e26ed..9f021dade 100644 --- a/src/lib/child-environment.ts +++ b/src/lib/child-environment.ts @@ -19,7 +19,13 @@ 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" && + key !== "HACK_AUTOSTART_PROJECT_ID" && + key !== "HACK_DAEMON_LOGIN_SERVICE" + ) { env[key] = value; } } @@ -33,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; } @@ -41,6 +65,12 @@ export function hackReexecEnvironment( base: Readonly> = process.env ): Record { 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]; if (typeof value === "string") { 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/doctor-metadata.ts b/src/lib/doctor-metadata.ts index 75bd38a20..0339162b1 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/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/machine-output.ts b/src/lib/machine-output.ts index e02871b8a..18e0a9477 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/src/lib/native-authored-command.ts b/src/lib/native-authored-command.ts index 41b459ef8..4cf4855a2 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?.enabled) { + 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..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"; @@ -273,7 +274,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; @@ -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/native-compose-command.ts b/src/lib/native-compose-command.ts index a02db2113..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, @@ -152,10 +153,16 @@ 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 autostart_global: AutostartGlobalView; + }>; readonly cwd: string; readonly path?: string; readonly project?: string; @@ -736,6 +743,7 @@ async function savedCommand(opts: { beforeHooksPending: true, hostHookPhase: state.hostHookPhase, services: [], + ...(await options.statusMetadata?.()), }, }), }); @@ -1029,6 +1037,7 @@ async function savedCommand(opts: { oneoff, }) ), + ...(await options.statusMetadata?.()), }, }), }); 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/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/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/src/lib/project-views.ts b/src/lib/project-views.ts index ea012940c..b5d30f70d 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -9,11 +9,16 @@ 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"; import { type ComposeServiceActivation, composeServiceActivation, } from "./compose-service-activation.ts"; import { composeServiceReadiness } from "./compose-service-readiness.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 { @@ -100,6 +105,7 @@ export type ProjectView = { readonly serviceActivation?: ComposeServiceActivation; readonly serviceReadiness?: Readonly>; readonly projectId?: string; + readonly autostart?: RegisteredProject["autostart"]; readonly name: string; readonly devHost: string | null; readonly repoRoot: string | null; @@ -357,6 +363,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, @@ -387,6 +394,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, @@ -462,6 +470,7 @@ export function serializeProjectSummary( nativeSelected: false, })), project_id: view.projectId ?? null, + autostart: serializeProjectAutostart(view.autostart), name: view.name, repo_root: view.repoRoot, dev_host: view.devHost, @@ -498,6 +507,7 @@ export function serializeProjectView( nativeSelected: false, })), 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/project.ts b/src/lib/project.ts index 66e4dea75..217766ae7 100644 --- a/src/lib/project.ts +++ b/src/lib/project.ts @@ -546,8 +546,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 96120d690..5a1b2f201 100644 --- a/src/lib/projects-registry.ts +++ b/src/lib/projects-registry.ts @@ -1,7 +1,11 @@ -import { createHash } from "node:crypto"; -import { realpath } from "node:fs/promises"; +import { Buffer } from "node:buffer"; +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"; 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"; import { ensureDir, pathExists } from "./fs.ts"; @@ -39,6 +43,10 @@ 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; +/** 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; @@ -46,6 +54,47 @@ 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; +} + +/** 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 waitingForEngine: 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(); + export interface RegisteredProject { readonly id: string; readonly name: string; @@ -57,11 +106,15 @@ 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; + /** Legacy paths copied from writable storage still need independent review before first opt-in. */ + readonly autostartRequiresReview?: true; } export interface ProjectsRegistry { readonly version: typeof REGISTRY_VERSION; readonly projects: readonly RegisteredProject[]; + readonly autostartGlobal?: AutostartGlobal; } export class AmbiguousProjectIdError extends Error { @@ -144,6 +197,119 @@ 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. 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"; + } +} + +/** 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. + * 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, + allowLegacyWritable = false +): 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(); + await assertAutostartFileAclsTrusted({ + path, + uid: process.getuid?.() ?? -1, + }); + if ( + !isTrustedAutostartRegistryFile({ + regular: info.isFile(), + fileUid: info.uid, + currentUid: process.getuid?.(), + // 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, + }) + ) { + 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. * @@ -151,12 +317,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: [] }; } @@ -177,19 +347,25 @@ 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"; 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()); + const path = opts?.registryPath ?? getRegistryPath(); + const text = opts?.requireAutostartTrust + ? await readTrustedAutostartRegistryText(path) + : await readRegistryText(path); if (text === null) { return { kind: "absent" }; } @@ -224,7 +400,59 @@ 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, + }; +} + +/** + * 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(); + if ( + observed.autostartGlobal || + observed.projects.some((project) => project.autostart) + ) { + return await readProjectsRegistry({ requireAutostartTrust: true }); + } + try { + return ( + await readAutostartMutationRegistry({ + registryPath: getRegistryPath(), + allowLegacyPermissionRepair: true, + requireObservedLegacyProject: false, + }) + ).registry; + } catch (error: unknown) { + if ( + !( + error instanceof AutostartRegistryTrustError || + error instanceof AutostartRegistryInvalidError + ) + ) { + 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 = { @@ -298,7 +526,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, @@ -310,6 +538,7 @@ async function upsertObservedRegistration(input: { }); return await commitRegistrationUpdate({ + registry: current, update, registryPath, signal: opts.signal, @@ -321,6 +550,7 @@ async function upsertObservedRegistration(input: { } async function commitRegistrationUpdate(opts: { + readonly registry: ProjectsRegistry; readonly update: Awaited>; readonly registryPath: string; readonly signal?: AbortSignal; @@ -334,7 +564,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 ); } @@ -387,7 +617,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, @@ -401,6 +631,7 @@ async function refreshUnchangedPrimary(opts: { return { status: "noop", project: primary }; } return await commitRegistrationUpdate({ + registry: current, registryPath, projectDir: opts.observation.projectDir, update: await updatePrimaryRegistration({ @@ -602,6 +833,626 @@ export async function resolveRegisteredProjectById(opts: { }; } +type AutostartRegistryOptions = { + readonly registryPath?: string; + 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, + waitingForEngine: 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[]; + readonly waitingForEngine?: boolean; + } +): 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, + waitingForEngine: opts.waitingForEngine ?? false, + 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, + }; + }, + }); +} + +/** 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; + 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 }; + } + const loginRun: AutostartLoginRun = { + ...run, + inProgress: false, + waitingForEngine: opts.waitingForEngine ?? false, + pending: opts.waitingForEngine ? run.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 AutostartRegistryInvalidError(); + } + 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 & { + 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; + } +): Promise { + let selected: RegisteredProject | undefined; + await mutateProjectAutostart({ + ...opts, + allowLegacyPermissionRepair: true, + requireObservedProjectReview: true, + 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, + }; + const { autostartRequiresReview: _reviewed, ...reviewed } = project; + selected = { ...reviewed, 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, + respectGlobalPause: true, + update: (project) => { + const marker = project.autostart; + if ( + project.autostartRequiresReview || + !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/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; + /** 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; + readonly releaseUnstartedClaim?: boolean; + } +): Promise { + let recorded = false; + await mutateProjectAutostart({ + ...opts, + respectGlobalPause: opts.onlyIfWanted, + update: (project) => { + if ( + project.autostart?.invalid || + project.autostart?.lastAttemptBootId !== opts.bootId || + (opts.onlyIfWanted && project.autostart?.paused) || + (opts.expectedBranch !== undefined && + project.autostart?.branch !== opts.expectedBranch) + ) { + 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, + ...(release ? { lastAttemptBootId: null } : {}), + }, + }; + }, + }); + return recorded; +} + +/** Every intent mutation rereads and publishes under the same registry lock as upsert. */ +async function mutateProjectAutostart( + opts: AutostartRegistryOptions & { + 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; + } +): Promise { + const registryPath = opts.registryPath ?? getRegistryPath(); + await ensureDir(dirname(registryPath)); + return await withProjectsRegistryLock({ + lockPath: `${registryPath}.lock`, + signal: opts.signal, + run: async () => { + 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, + 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 + ); + if (permissionsRepaired) { + opts.onPermissionsRepaired?.(); + } + } + return selected; + }, + }); +} + +/** + * An explicit first opt-in may copy current-user-owned unmarked v4 registrations + * 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; +}> { + 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(); + } + 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. */ export async function removeProjectsById(opts: { readonly ids: readonly string[]; @@ -613,7 +1464,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 }); } @@ -626,7 +1477,7 @@ export async function removeProjectsById(opts: { await writeRegistryAtomic( getRegistryPath(), { - version: REGISTRY_VERSION, + ...current, projects: next, }, opts.signal @@ -683,7 +1534,80 @@ 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.waitingForEngine === undefined || + typeof run.waitingForEngine === "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, + waitingForEngine: run.waitingForEngine ?? false, + 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 { @@ -706,6 +1630,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 +1643,80 @@ function parseProject(value: unknown): RegisteredProject | null { createdAt, ...(lastSeenAt ? { lastSeenAt } : {}), ...(worktrees.length > 0 ? { worktrees } : {}), + ...(autostart ? { autostart } : {}), + ...(value.autostartRequiresReview === true + ? { autostartRequiresReview: true } + : {}), + }; +} + +/** 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 []; @@ -771,9 +1767,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, null, 2)}\n`, + text, signal, }); } 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 28fc4186e..8505a8a66 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"; @@ -31,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. */ @@ -72,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), @@ -131,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; @@ -157,6 +267,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 +331,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 +369,8 @@ export async function run( timeoutMs: options.timeoutMs, signal, beforeSpawn, - onSpawn: options.onSpawn, - onExit: options.onExit, + onSpawn, + onExit, }); } const ownsProcessGroup = @@ -321,9 +456,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; } @@ -377,6 +512,9 @@ function installSubprocessSignalForwarding(opts: { return { exitCode: () => exitCode, dispose: () => { + if (!active) { + return; + } active = false; process.off("SIGINT", onInterrupt); process.off("SIGTERM", onTerminate); @@ -411,6 +549,7 @@ function installSubprocessTimeout(opts: { } } }; + let disposed = false; const timer = setTimeout(() => { timedOut = true; signalProcessGroup("SIGTERM"); @@ -418,6 +557,10 @@ function installSubprocessTimeout(opts: { }, opts.timeoutMs); return { dispose: () => { + if (disposed) { + return; + } + disposed = true; clearTimeout(timer); if (forceKillTimer) { clearTimeout(forceKillTimer); diff --git a/src/templates.ts b/src/templates.ts index 86e2ef05c..ab5330a6a 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -959,6 +959,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-checkout-trust.test.ts b/tests/autostart-checkout-trust.test.ts new file mode 100644 index 000000000..854c3a378 --- /dev/null +++ b/tests/autostart-checkout-trust.test.ts @@ -0,0 +1,337 @@ +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("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("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); + 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.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"); + 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", + ...(kind === "bind" + ? {} + : { lifecycle: { up: { before: [`sh ${quoteShellWord(script)}`] } } }), + }) + ); + await writeFile( + join(bin, "docker"), + `#!${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 === "bind" + ? script + : kind === "directory" + ? join(root, "projects") + : join(projectDir, "hack.config.json"); + const mode = kind === "hook" || kind === "bind" ? 0o666 : 0o770; + await chmod(unsafe, mode); + 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( + 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); + expect((await stat(unsafe)).mode & 0o777).toBe(mode); +}); 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-cli.test.ts b/tests/autostart-cli.test.ts new file mode 100644 index 000000000..f4ee0f867 --- /dev/null +++ b/tests/autostart-cli.test.ts @@ -0,0 +1,469 @@ +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 { + 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"; +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 = { + 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; +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", + 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, + 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], + autostartGlobal: globalState, + }); + }, + setProjectAutostart: (options: unknown) => { + mutations.push(options); + return Promise.resolve(project); + }, + removeProjectAutostart: (options: unknown) => { + mutations.push(options); + 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); + return Promise.resolve([project]); + }, + }, +}); +beforeAll(() => { + projectMock.activate(); + startupMock.activate(); + viewMock.activate(); + registryMock.activate(); + gitMock.activate(); +}); +afterAll(() => { + gitMock.deactivate(); + registryMock.deactivate(); + viewMock.deactivate(); + startupMock.deactivate(); + projectMock.deactivate(); +}); +beforeEach(() => { + mutations.length = 0; + reads = 0; + explicitRuns = 0; + explicitEngineWaitMs = undefined; + linkedRoots.clear(); + globalState = getDefaultAutostartGlobal(); +}); +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", project: undefined, json: false }, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + [] + ), + raw, + }, + }) + ); + expect(result.code).toBe(0); + expect(mutations).toEqual([ + { + id: "example-id", + branch: "preview", + observedProject: undefined, + onPermissionsRepaired: expect.any(Function), + }, + ]); + expect(result.output).toContain("hack daemon install"); +}); + +test("pause and resume without a project change global intent independently of project markers", async () => { + await capture(() => + autostartCommand.subcommands[3].handler({ + ctx, + args: { + options: { project: undefined, json: false }, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + [] + ), + raw, + }, + }) + ); + await capture(() => + autostartCommand.subcommands[4].handler({ + ctx, + args: { + options: { project: undefined, json: false }, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + [] + ), + raw, + }, + }) + ); + expect(mutations).toEqual([ + { global: true, paused: true }, + { global: true, paused: false }, + ]); + expect(reads).toBe(2); +}); + +test("malformed project selector refuses before registry access or mutation", async () => { + await expect( + autostartCommand.subcommands[1].handler({ + ctx, + args: { + options: { project: undefined, json: false }, + 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({ + 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, + }, + }, + ], + }, + }); + expect(result.output).not.toContain("private-admission-id"); + 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, + paused: true, + lastResult: { + status: "skipped", + reason: "interactive_required", + at: "2026-01-01T00:00:00Z", + }, + }) + ).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: { + 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({ + autostart_global: serializeAutostartGlobal( + getDefaultAutostartGlobal(), + false + ), + projects: [ + { + project_id: "example-id", + name: "example", + autostart: serializeProjectAutostart({ ...marker, paused: true }), + }, + { + project_id: "removed-id", + autostart: serializeProjectAutostart(undefined), + }, + ], + 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", project: undefined, json: false }, + positionals: parsePositionalsForCommand( + autostartCommand.subcommands[0].positionals, + ["example"] + ), + raw, + }, + }) + ).rejects.toThrow("Autostart currently requires the primary checkout"); + 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: { + schema: AUTOSTART_JSON_SCHEMA, + 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([]); +}); + +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: { + schema: AUTOSTART_JSON_SCHEMA, + 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-docker-trust.test.ts b/tests/autostart-docker-trust.test.ts new file mode 100644 index 000000000..54aa8ad7a --- /dev/null +++ b/tests/autostart-docker-trust.test.ts @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +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"; + +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 exposing values", async () => { + expect(() => assertAutostartDockerConfigTrusted({ home })).not.toThrow(); + 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 }); + 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(); +}); + +test("network contexts and direct 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", + "npipe:////./pipe/docker_engine", + "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:") || + host.startsWith("tcp:") || + host.startsWith("npipe:") + ) { + expect(check).toThrow(); + } else { + expect(check).not.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" } } }), + { mode: 0o600 } + ); + expect(() => + assertAutostartDockerConfigTrusted({ + home, + env: { DOCKER_CONTEXT: name }, + systemPluginDirectories: [], + }) + ).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 }); + } +}); + +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(); +}); diff --git a/tests/autostart-down-target.test.ts b/tests/autostart-down-target.test.ts new file mode 100644 index 000000000..16da5c95c --- /dev/null +++ b/tests/autostart-down-target.test.ts @@ -0,0 +1,399 @@ +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 { 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"; +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 marked of ["old-branch", "new-branch", null, "other-branch"]) { + 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", + 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" }); + // 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 }]); + }); +} + +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"); + 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; + if (child.exitCode === null) { + child.kill("SIGKILL"); + } + await output; + } + }, 15_000); +} diff --git a/tests/autostart-executable-trust.test.ts b/tests/autostart-executable-trust.test.ts new file mode 100644 index 000000000..74cee8106 --- /dev/null +++ b/tests/autostart-executable-trust.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + 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"; +import { + DARWIN_ADMIN_GID, + isDarwinAdminDirectory, +} from "../src/lib/path-trust.ts"; + +let root = ""; +let bin = ""; +let program = ""; +beforeEach(async () => { + root = await realpath( + 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("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, + }); + await expect( + assertAutostartExecutablePathTrusted({ path: `${homebrew}:${bin}` }) + ).rejects.toMatchObject({ code: "E_PERMISSION" }); + 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( + 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"); + } +}); + +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 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( + assertAutostartExecutablePathTrusted( + { path: bin }, + { + platform: "darwin", + inspect: async () => ({ exitCode, stdout: "private-sentinel" }), + } + ) + ).rejects.toThrow( + "Automatic startup refused an unsafe executable search path." + ); + } +}); + +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/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-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..607f12220 --- /dev/null +++ b/tests/autostart-lifecycle-intent.test.ts @@ -0,0 +1,540 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmod, + mkdir, + mkdtemp, + realpath, + 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"; +import { + assertAutostartProjectCurrent, + withAutostartProjectLock, +} from "../src/lib/autostart-project-lock.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, + }, + }, + ], + }), + { mode: 0o600 } + ); +} +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"), + overrides: Readonly> = {} +) { + 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", + HACK_AUTOSTART_PROJECT_ID: login ? "sample" : undefined, + ...overrides, + }, + }), + 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 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(false); + expect(classifyAutostartExit(result)).toEqual({ + status: "skipped", + reason: "pending_env_migration", + }); +}); + +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"), + '{"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); +}); + +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"); + } +}); + +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); +}); + +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/autostart-path-acl.test.ts b/tests/autostart-path-acl.test.ts new file mode 100644 index 000000000..4473e0d8c --- /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, realpath, 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 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"}', { + 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/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-routing-assets.test.ts b/tests/autostart-routing-assets.test.ts new file mode 100644 index 000000000..5d172619e --- /dev/null +++ b/tests/autostart-routing-assets.test.ts @@ -0,0 +1,454 @@ +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 { + assertGlobalBackendStartAllowed, + globalDnsRecoveryAllowed, + globalLoggingUpArguments, + 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_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 "../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).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"]); +}); + +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 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); + } + await secure(); + for (const [path, text] of assets) { + expect(await readFile(path, "utf8")).toBe(text); + 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]) { + 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); +}); + +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", + ]); +}); + +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/autostart-start-policy.test.ts b/tests/autostart-start-policy.test.ts new file mode 100644 index 000000000..8ebc487fa --- /dev/null +++ b/tests/autostart-start-policy.test.ts @@ -0,0 +1,575 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + open, + readdir, + readFile, + rename, + 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("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"), + 'name = "example"\n[[startup]]\nrun = "synthetic-command"\n', + { mode: 0o600 } + ); + expect(await autostartStartPolicyRefusal(opts)).toBe( + "host_commands_require_manual_start" + ); +}); + +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("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", + "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 [ + [ + { 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("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", + "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); + } +}); + +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 [ + "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" + ); +}); + +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); + } +}); 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 }); + } +}); 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/autostart-view.test.ts b/tests/autostart-view.test.ts new file mode 100644 index 000000000..641316b71 --- /dev/null +++ b/tests/autostart-view.test.ts @@ -0,0 +1,149 @@ +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, + waitingForEngine: false, + 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, + 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 }); + 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, + waiting_for_engine: false, + 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/child-environment.test.ts b/tests/child-environment.test.ts index de0724504..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"; @@ -256,3 +257,72 @@ test.skipIf(!compiledCli)( expect(result).toBe(0); } ); + +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", + }; + 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", + }); + expect( + hackReexecEnvironment({ + HACK_AUTOSTART_PROJECT_ID: "fixture-project", + HACK_DAEMON_LOGIN_SERVICE: "1", + }) + ).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?: Record + ) => { + const child = Bun.spawn(["git", "-C", root, ...args], { + env: childEnv ?? fixtureGitEnv({ env }), + 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( + { ...fixtureGitEnv({ 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/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 new file mode 100644 index 000000000..021bd1b4b --- /dev/null +++ b/tests/daemon-autostart.test.ts @@ -0,0 +1,1366 @@ +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"; +import { + type AutostartDependencies, + autostartComposeModel, + autostartRoutingInstalled, + autostartRuntimeRefusal, + autostartServiceEnvironment, + autostartServicePath, + buildAutostartProjectInvocation, + canAdoptAutostartEnvironment, + classifyAutostartContainers, + classifyAutostartExit, + completedDependencyServices, + createAutostartProductionDependencies, + resolveAutostartEngineWaitMs, + runAutostartAtLogin, + runAutostartSubprocess, +} from "../src/daemon/autostart.ts"; +import { assertAutostartExecutablePathTrusted } from "../src/lib/autostart-executable-trust.ts"; +import { + beginAutostartLoginRun, + claimProjectAutostart, + readAutostartGlobal, + readProjectsRegistry, + removeProjectAutostart, + setAutostartGlobalPaused, + setProjectAutostart, + 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; + +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")); + 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"); + 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, + }, + })), + }) + ); + await chmod(registryPath, 0o600); +} + +async function run(signal?: AbortSignal) { + return 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: 6000 }, + { id: "bravo", at: 6500 }, + { id: "charlie", at: 7000 }, + ]); + 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("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 }); + 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([]); + expect((await readAutostartGlobal({ registryPath })).loginRun.failed).toEqual( + [] + ); +}); + +test("engine wait uses bounded exponential backoff and leaves attempts unclaimed for a later pass", async () => { + await seed(["alpha"]); + 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([]); + 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: [], + }); +}); + +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 }; + 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"]); + expect((await readAutostartGlobal({ registryPath })).loginRun.failed).toEqual( + [] + ); +}); + +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("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( + [ + 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("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 () => { + 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" }); +}); + +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(); +}); + +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" }); +}); + +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; + } + } +}); + +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); +}); + +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"]); +}); + +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"]); + }); +} + +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 }); + 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); + 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: "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" } } }), + { mode: 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/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..a33d4b20a 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, @@ -496,6 +497,23 @@ 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(text).toContain("%h/.local/bin:%h/.bun/bin:%h/.hack/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", + ]); expect(extractSystemdExecProgram({ unitText: text ?? "" })).toBe( "/opt/my tools%/hack" ); 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"); } 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/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); +}); 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 }); + } + }); +} diff --git a/tests/launchd.test.ts b/tests/launchd.test.ts index a36bc8ba3..05e5f7d82 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"); @@ -89,6 +95,7 @@ describe("launchd plist generation", () => { expect(plist).toContain("/bin"); expect(plist).toContain(".bun/bin"); expect(plist).toContain(".hack/bin"); + expect(plist).not.toContain("/Users/testuser/.docker/bin"); }); test("plist is valid XML structure", () => { 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", 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..4d7f7ae25 --- /dev/null +++ b/tests/models/tla/autostart-start-stop/COVERAGE.md @@ -0,0 +1,74 @@ +# 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` 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. | +| `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. +`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. +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. +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. +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. 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/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..e618f0e84 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 { chmod, 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,83 @@ 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"); + } + // 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"), + 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/project-autostart.test.ts b/tests/project-autostart.test.ts new file mode 100644 index 000000000..42a342e16 --- /dev/null +++ b/tests/project-autostart.test.ts @@ -0,0 +1,1567 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + open, + readFile, + rename, + rm, + stat, + symlink, + truncate, + writeFile, +} from "node:fs/promises"; +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, + setAutostartLoginWaitingForEngine, + setProjectAutostart, + setProjectAutostartPaused, + touchProjectRegistration, + 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")] }), + { mode: 0o600 } + ); +}); + +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"); +}); + +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("Do not repair writable files with in-place chmod"); + } +}); + +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); +}); + +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(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 })); + await writeFile(composeFile, "services: {}\n"); + await writeFile(envFile, ""); + return { + projectRoot: join(root, name), + 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, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, + }); + 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); +}); +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, + }); +}); + +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 { + const before = await writer.stat(); + let receipts = 0; + await setProjectAutostart({ + id: "alpha", + registryPath, + observedProject: { + repoRoot: project.projectRoot, + projectDir: project.projectDir, + }, + 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 }); + const other = await createTrustProject("beta"); + await setProjectAutostart({ + id: "beta", + registryPath, + observedProject: { + repoRoot: other.projectRoot, + projectDir: other.projectDir, + }, + onPermissionsRepaired: () => { + receipts += 1; + }, + }); + expect(receipts).toBe(1); + } finally { + await writer.close(); + } +}); + +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); +}); + +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("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("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"); + 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 }); + 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 = { + 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-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-unattended.test.ts b/tests/runtime-backend-unattended.test.ts new file mode 100644 index 000000000..4397e3b96 --- /dev/null +++ b/tests/runtime-backend-unattended.test.ts @@ -0,0 +1,161 @@ +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("--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"); +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); +}); + +for (const scenario of [ + "unsafe-existing", + "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-")); + 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" || scenario === "trusted-unicode-bind" + ? { + type: "bind", + source: + scenario === "trusted-unicode-bind" + ? "/fixture/unicode-é/.hack/caddy/pki/authority.crt" + : "/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(${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);} + 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, + unattendedTrustedBinds: async () => [ + { + source: "/fixture/unicode-é/.hack/caddy/pki/authority.crt", + target: "/data", + readOnly: 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 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( + 2 + ); + } + }); +} diff --git a/tests/runtime-backend.test.ts b/tests/runtime-backend.test.ts index 5106dc713..50e413c21 100644 --- a/tests/runtime-backend.test.ts +++ b/tests/runtime-backend.test.ts @@ -6,12 +6,15 @@ 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; forwardSignals?: boolean; + settleProcessGroupOnCancel?: boolean; }[] = []; const execCalls: string[][] = []; +const execResults: { exitCode: number; stdout: string; stderr: string }[] = []; const shellMock = await registerScopedModuleMock({ importerPath: import.meta.path, @@ -19,7 +22,15 @@ const shellMock = await registerScopedModuleMock({ overrides: { exec: async (cmd: readonly string[]) => { execCalls.push([...cmd]); - return { 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]); @@ -31,6 +42,7 @@ const shellMock = await registerScopedModuleMock({ readonly stdout?: string; readonly timeoutMs?: number; readonly forwardSignals?: boolean; + readonly settleProcessGroupOnCancel?: boolean; } = {} ) => { runCalls.push([...cmd]); @@ -38,8 +50,9 @@ const shellMock = await registerScopedModuleMock({ stdout: opts.stdout, timeoutMs: opts.timeoutMs, forwardSignals: opts.forwardSignals, + settleProcessGroupOnCancel: opts.settleProcessGroupOnCancel, }); - return 0; + return runResults.shift() ?? 0; }, findExecutableInPath: () => "/usr/bin/docker", CommandError: class CommandError extends Error {}, @@ -76,8 +89,10 @@ beforeAll(() => { beforeEach(() => { runCalls.length = 0; + runResults.length = 0; runOpts.length = 0; execCalls.length = 0; + execResults.length = 0; }); afterAll(() => { @@ -106,6 +121,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 +502,484 @@ test("automatic bootstrap preserves output and forwards cancellation to its boun stdout: "stderr", timeoutMs: 600_000, forwardSignals: true, + 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: JSON.stringify({ + name: "fixture", + services: { app: { image: "example.invalid/app" } }, + }), + 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: JSON.stringify({ + name: "fixture", + services: { app: { image: "example.invalid/app" } }, + }), + 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: JSON.stringify({ + name: "fixture", + services: { app: { image: "example.invalid/app" } }, + }), + 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([]); +}); + +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); + }); +} diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index 77f73fae2..39c432ef4 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -8,6 +8,10 @@ import { serializeProjectView, } 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"; @@ -961,3 +965,105 @@ test("an event refresh retains known job completion and publishes a subsequent c false, ]); }); + +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, + waitingForEngine: false, + 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/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(); + } +}); 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); diff --git a/tests/status-native.test.ts b/tests/status-native.test.ts index a629e3f1d..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,30 @@ 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: [ + { + 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 +126,38 @@ 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; + readonly autostart_global: unknown; + }; }; expect(parsed.ok).toBe(true); - expect(parsed.data).toEqual(observed); + 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: { + status: "skipped", + reason: "interactive_required", + at: "2026-01-01T00:00:00Z", + }, + }, + }); expect(out).not.toContain("stopped"); }); @@ -112,6 +169,8 @@ 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 }, projectOpt: undefined, @@ -125,6 +184,8 @@ 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 }, projectOpt: undefined, @@ -155,11 +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: serializeProjectAutostart(undefined), + autostart_global: serializeAutostartGlobal( + getDefaultAutostartGlobal(), + false + ), + }); dispatched.push(input); return 7; }, @@ -172,11 +242,19 @@ 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({ + serviceReady: async () => false, + readRegistry: async () => ({ version: 1, projects: [] }), cwd: root, native, projectOpt: undefined,