diff --git a/.agentctl/project.toml b/.agentctl/project.toml index bd23ec7c..158460cb 100644 --- a/.agentctl/project.toml +++ b/.agentctl/project.toml @@ -8,6 +8,7 @@ root_markers = ["flake.nix", "modules", "dots"] [environment] kind = "nix-develop" command = ["nix", "develop", "--accept-flake-config", "--command"] +preflight = ["true"] inherit = ["HOME", "USER", "LANG", "TERM", "SSH_AUTH_SOCK", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "WAYLAND_DISPLAY", "DISPLAY"] unset = ["PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV", "_PYTHON_SYSCONFIGDATA_NAME", "_PYTHON_HOST_PLATFORM", "PYTHONPYCACHEPREFIX"] diff --git a/docs/sinnixd.md b/docs/sinnixd.md index 11f36adc..15a2cee3 100644 --- a/docs/sinnixd.md +++ b/docs/sinnixd.md @@ -41,7 +41,9 @@ agentctl agent --project sinnix --checkout default --prompt-file ./prompt.md --b agentctl task create sinnix 'Follow-up title' --description 'Bounded task description.' --type task --priority 2 --label area:agentctl --parent sinnix-oy37 --dependency depends-on:sinnix-oy37.9 --request-id 2d8f1e3a-61d0-4d3c-bb9a-9e8baa3a5cac ``` -The service passes a declarative, non-empty `sinnix.services.sinnixd.projectRoots` list as repeated `--project-root` arguments. It defaults to the Sinnix, Polylogue, and Sinex roots. Sinnixd loads only those `.agentctl/project.toml` adapters and does not scan arbitrary directories. Each descriptor is schema-versioned, identifies its repository root markers, declares the execution environment, and publishes named operation metadata. +The service passes a declarative, non-empty `sinnix.services.sinnixd.projectRoots` list as repeated `--project-root` arguments. It defaults to the registered Sinnix project entries. Sinnixd loads only those `.agentctl/project.toml` adapters and does not scan arbitrary directories. Each descriptor is schema-versioned, identifies its repository root markers, declares the execution environment, and publishes named operation metadata. A descriptor with a `[workspace]` is agent-capable and must declare non-empty shell-free argv lists for both `environment.command` and `environment.preflight`; the latter runs inside the former from the revalidated checkout before any backend starts. `agentctl project get ` publishes that capability and both public argv lists. + +The environment contract gate evaluates the configured `sinnix.services.sinnixd.projectRoots` and runs `sinnixd-project-environment-check` against the real descriptors. It reports every missing or invalid required declaration and stops before a host configuration is built or activated. The gate runs for `nix run .#switch`, `nix run .#boot`, `nix run .#test-system`, and the devshell `switch`, `boot`, and `test-system` commands. `test-vm` remains separate because it only builds a guest image and does not activate the live host. Roll out descriptor commits before this Sinnix commit. The minimum declaration for every external agent-capable descriptor is `preflight = ["true"]` under `[environment]`; a project may use a stronger cheap project-native readiness argv instead. The current external prerequisites are Polylogue, Sinex, and Lynchpin, and all three descriptors must satisfy this contract before any gated command is run. `job start` accepts a project ID, one declared operation name, an optional workspace binding, and an optional JSON parameters object. It never accepts an arbitrary command. Its optional workspace binding launches in that registered checkout and durably records the checkout ID and exact starting HEAD, so later publication can reject stale verification. Declared operations and internal synthetic foreground commands construct the same durable generic-job spec, record, transient user `.service` launch, log artifact, reconciliation, wait, and cancellation route. A descriptor may set a bounded `timeout_seconds`; that becomes the transient service limit, rather than a caller-controlled duration. The typed attested-agent contract also accepts an optional validated Beads binding from the gateway. It is public durable provenance, not prompt material: canonical bead/project/checkout refs, launch task revision and etag, optional claim receipt, request ID, and display-only work item. The only additional public starts are the constrained typed contracts below. @@ -223,7 +225,7 @@ If any verification or cutover command fails, leave Sinnixd stopped. Before the Both routes use the same UUID job ID, transient user service, cancellation, reconciliation, `job get/list/logs/result/wait`, and bounded artifact readers as declared operations. Their durable public record contains the principal, job kind, canonical project and checkout identity, redacted argv digest or prompt digest, and bounded artifact references. It never stores raw shell argv arguments after launch, prompt text, environment values, or credentials. -Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Agent handoff includes `--registered-project`, `--expected-git-common-dir`, and the canonical checkout path; nested scope creation remains disabled, so the native runner provides backend execution and native attestation while the shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after agent handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. +Typed jobs accept no environment overlay. The daemon creates the `env -i` environment from the declared project environment and fixed `SINNIXD_*` identity fields. Immediately before execution, the contract runner verifies those fields, rechecks the exact registered project, canonical worktree root, common Git directory, porcelain worktree membership, and recorded HEAD. A changed, missing, symlinked, or spoofed identity fails closed. Every attested agent runs its mandatory environment `preflight` from the revalidated checkout, then invokes the native backend through the same descriptor-owned `environment.command`. A missing, failed, unavailable, or 30-second `agent-preflight-timeout` preflight terminates the typed job before backend implementation starts and retains an actionable runner error in the bounded log. Attested-agent private inputs use schema v2; v1 records fail closed as stale contract input and must be relaunched. The shared transient user service remains the sole process, cgroup, timeout, and cancellation authority. Private launch inputs are mode 0600, removed before shell execution, and removed after handoff or every terminal lifecycle outcome, including confirmed launch failure. Native private logs are removed after handoff; only the bounded shared log and result artifacts remain addressable. Each record is stored under `$XDG_STATE_HOME/sinnixd` and contains safe operation identity, environment key names, and its bounded-read log artifact path. Record replacement fsyncs the containing directory, and newly created state directories are synchronized before they contain durable evidence. The `sinnixd-job-*.service` dynamic runtime surface and its record capture lane are declared with the daemon, rather than with any MCP frontend. Internal foreground argv is launch-only: the durable record has only a SHA-256 digest and constant display metadata, never raw argv or environment values. The systemd-launched capture helper drains output but writes at most 1 MiB per job; it creates its overflow marker with the first discarded byte, so a live log reader can see truncation before the producer exits. It also fsyncs a completion marker only after the captured process exits successfully and all bounded outputs are durable. It does not own a PID, process state, queue, task, workspace, or retry policy. A job ID deterministically derives its unit name. Every `systemd-run` and `systemctl` call has a short finite bound. `job.wait` caps each reconciliation call to its remaining deadline, so a stalled user manager cannot hold a wait or reserved control worker indefinitely. After a daemon restart, `get`, `list`, `wait`, and `cancel` reload the record and reconcile with the user manager. If `systemd-run` loses its reply but `show` finds the transient unit, `job start` returns the reconciled systemd state. If both the launch reply and its first reconciliation are unavailable, `job start` returns a durable nonterminal `launch-unknown` result with the stable job ID and unit. Later `get`, `wait`, and `cancel` use that same identity to reconcile it. A confirmed absent launch becomes terminal `launch-failed`. A confirmed missing unit after launch remains terminal `missing`; an unreachable or timed-out systemd observation is durable nonterminal `observation-unknown` until a later observation repairs it. Cancellation persists its intent before asking systemd to stop the service, then preserves an observed systemd success, timeout, or failure result. A `cancelled` result needs matching systemd signal evidence, or a durably recorded successful stop acknowledgement for the observed invocation when systemd has already garbage-collected the transient unit. If a stop times out and the unit later disappears, the job remains nonterminal `outcome-unknown` instead of treating the missing unit's default success fields as an exit result. A later authoritative systemd observation can repair that state. A typed result can prove semantic success after collection only when its content is valid and the capture completion marker proves the producer exited successfully; an empty, partial, malformed, or unmarked result is not completion evidence. A schema-v3 attested-agent record also carries forward its native completion only when systemd still reports an inactive loaded success, its durable lifecycle is `succeeded` with exit status zero, its bounded last-message artifact is valid, and no cancellation intent exists. Existing false terminal success or cancellation records without this evidence are reopened lazily by `get`, `list`, `wait`, or `cancel` and reconciled under the same rules. Systemd remains authoritative for the process, cgroup, timeout, terminal result, cancellation, and journal evidence. diff --git a/flake/command-registry.nix b/flake/command-registry.nix index 0bcc7cf1..dea542fb 100644 --- a/flake/command-registry.nix +++ b/flake/command-registry.nix @@ -112,6 +112,22 @@ let done < <(sinnix-rebuild-override consume) fi ''; + agentEnvironmentContract = '' + mapfile -t agentctl_project_roots < <( + ${pkgs.nix}/bin/nix eval \ + "$_flake_dir#nixosConfigurations.sinnix-prime.config.sinnix.services.sinnixd.projectRoots" \ + --json \ + --impure \ + "''${nix_override_args[@]}" \ + | ${pkgs.jq}/bin/jq -r '.[]' + ) + agentctl_environment_arguments=() + for agentctl_project_root in "''${agentctl_project_roots[@]}"; do + agentctl_environment_arguments+=(--project-root "$agentctl_project_root") + done + ${scriptPkgs.sinnixd}/bin/sinnixd-project-environment-check \ + "''${agentctl_environment_arguments[@]}" + ''; # Single source of truth for rebuild concurrency + resource containment, so # `nix run .#switch` (this file's appCommands) and the devshell `switch` # binary (flake/dev-shell.nix's mkNhCommand) can't drift apart: both must @@ -403,6 +419,7 @@ in rebuildLock rebuildContainmentFlags rebuildDefaultArgs + agentEnvironmentContract rebuildServicePath localInputOverrideArgs avoidRepoCwdForActivation @@ -498,6 +515,7 @@ in ${rebuildLock "test-vm"} ${localInputOverrideArgs} ${rebuildDefaultArgs} + # build-vm builds a separate guest and does not activate the live host. sudo ${pkgs.systemd}/bin/systemd-run \ --quiet \ --collect \ @@ -516,13 +534,14 @@ in }; test-system = { - description = "Test configuration without applying it to the system (nh os test)"; + description = "Build and activate a temporary host configuration (nh os test)"; script = '' ${resolveFlakeDir} ${rebuildLock "test-system"} ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${rebuildDefaultArgs} + ${agentEnvironmentContract} ${pkgs.systemd}/bin/systemd-run \ --user \ --quiet --collect --pipe --service-type=exec --wait \ @@ -539,13 +558,14 @@ in }; boot = { - description = "Build + set boot default, activate on next reboot (nh os boot)"; + description = "Build and set the boot default for activation on the next reboot (nh os boot)"; script = '' ${resolveFlakeDir} ${rebuildLock "boot"} ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${rebuildDefaultArgs} + ${agentEnvironmentContract} ${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch _rebuild_status=0 ${pkgs.systemd}/bin/systemd-run \ @@ -572,6 +592,7 @@ in ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${rebuildDefaultArgs} + ${agentEnvironmentContract} ${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch _rebuild_status=0 ${pkgs.systemd}/bin/systemd-run \ @@ -653,12 +674,12 @@ in { name = "boot"; category = "Core"; - description = "Build + set boot default — safer, reboot to activate (nh os boot)"; + description = "Build and set the boot default for activation on the next reboot (nh os boot)"; } { name = "test-system"; category = "Core"; - description = "Test host config without persisting (nh os test)"; + description = "Build and activate a temporary host configuration with nh os test"; } { name = "test-vm"; diff --git a/flake/dev-shell.nix b/flake/dev-shell.nix index e112183e..59d37330 100644 --- a/flake/dev-shell.nix +++ b/flake/dev-shell.nix @@ -37,6 +37,7 @@ rebuildServicePath localInputOverrideArgs resolveFlakeDir + agentEnvironmentContract avoidRepoCwdForActivation switchFallback ; @@ -49,6 +50,7 @@ ${avoidRepoCwdForActivation} ${localInputOverrideArgs} ${commandRegistry.rebuildDefaultArgs} + ${agentEnvironmentContract} ${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch _rebuild_status=0 @@ -124,6 +126,7 @@ ${localInputOverrideArgs} ${commandRegistry.rebuildDefaultArgs} + # build-vm builds a separate guest and does not activate the live host. # Not `exec`: sudo may close inherited fds (incl. the lock fd held # above), so the lock must stay held by this shell until the build # actually completes rather than being handed off across the hop. diff --git a/flake/tests.nix b/flake/tests.nix index e23b3544..5fa33d31 100644 --- a/flake/tests.nix +++ b/flake/tests.nix @@ -32,6 +32,7 @@ in ./tests/capture-primary.nix ./tests/backup.nix ./tests/agent-environment.nix + ./tests/command-registry.nix ./tests/agent-parity.nix ./tests/swarm-fixture.nix ./tests/observability.nix diff --git a/flake/tests/command-registry.nix b/flake/tests/command-registry.nix new file mode 100644 index 00000000..76592914 --- /dev/null +++ b/flake/tests/command-registry.nix @@ -0,0 +1,41 @@ +# Verify the rendered rebuild entrypoints retain the environment contract gate. +{ inputs, ... }: +{ + perSystem = + { + pkgs, + system, + sinnixScriptRegistry, + ... + }: + let + commandRegistry = import ../command-registry.nix { + inherit inputs pkgs system sinnixScriptRegistry; + }; + rendered = pkgs.runCommand "command-registry-environment-gate" { } '' + cat > "$out" <<'EOF' + --- switch --- + ${commandRegistry.appCommands.switch.script} + --- boot --- + ${commandRegistry.appCommands.boot.script} + --- test-system --- + ${commandRegistry.appCommands.test-system.script} + --- test-vm --- + ${commandRegistry.appCommands.test-vm.script} + EOF + # Provably fails when a covered command loses its gate or the gate is + # moved after the command invocation. + test "$(grep -c 'sinnixd-project-environment-check' "$out")" = 3 + if awk '/--- switch ---/{section=1} /--- boot ---/{section=0} section && /sinnixd-project-environment-check/{gate=NR} section && /nh os switch/{switch_line=NR} END{exit !(gate && switch_line && gate < switch_line)}' "$out"; then :; else exit 1; fi + if awk '/--- boot ---/{section=1} /--- test-system ---/{section=0} section && /sinnixd-project-environment-check/{gate=NR} section && /nh os boot/{boot=NR} END{exit !(gate && boot && gate < boot)}' "$out"; then :; else exit 1; fi + if awk '/--- test-system ---/{section=1} /--- test-vm ---/{section=0} section && /sinnixd-project-environment-check/{gate=NR} section && /nh os test/{test=NR} END{exit !(gate && test && gate < test)}' "$out"; then :; else exit 1; fi + if awk '/--- test-vm ---/{section=1} section && /sinnixd-project-environment-check/{bad=1} END{exit bad}' "$out"; then :; else exit 1; fi + ''; + in + { + checks.command-registry-environment-gate = pkgs.runCommand "command-registry-environment-gate-check" { inherit rendered; } '' + test -s "$rendered" + touch "$out" + ''; + }; +} diff --git a/pkgs/sinnixd/pyproject.toml b/pkgs/sinnixd/pyproject.toml index 83d0eab8..05d7ff26 100644 --- a/pkgs/sinnixd/pyproject.toml +++ b/pkgs/sinnixd/pyproject.toml @@ -14,6 +14,7 @@ agentctl = "sinnixd.cli:main" sinnixd = "sinnixd.cli:daemon_main" sinnixd-capture = "sinnixd.jobs:capture_cli" sinnixd-contract-runner = "sinnixd.runner:main" +sinnixd-project-environment-check = "sinnixd.projects:project_environment_check_main" sinnixd-task-reconcile = "sinnixd.tasks:task_reconcile_main" [tool.setuptools.packages.find] diff --git a/pkgs/sinnixd/sinnixd/contracts.py b/pkgs/sinnixd/sinnixd/contracts.py index d31f92cc..ae2d94e8 100644 --- a/pkgs/sinnixd/sinnixd/contracts.py +++ b/pkgs/sinnixd/sinnixd/contracts.py @@ -134,6 +134,11 @@ def start_agent( if not self.native_runner.is_file() or not os.access(self.native_runner, os.X_OK): raise ContractError("native agent runner is unavailable") checkout = self.projects.checkout(project_id, checkout_id) + project = self.projects.get(project_id) + if not project.environment.preflight: + raise ContractError( + f"project {project_id} does not declare an agent environment preflight" + ) binding = self._bead_binding(bead_binding, checkout) job_id = str(uuid4()) prompt_path = self.inputs_root / f"{job_id}.prompt" @@ -147,11 +152,13 @@ def start_agent( **({"bead_binding": binding} if binding is not None else {}), } private = { - "schema_version": 1, + "schema_version": 2, "job_id": job_id, "kind": "attested-agent", "principal": principal, "checkout": checkout.to_dict(), + "environment_command": list(project.environment.command), + "environment_preflight": list(project.environment.preflight), "backend": backend, "model": model, "effort": effort, diff --git a/pkgs/sinnixd/sinnixd/projects.py b/pkgs/sinnixd/sinnixd/projects.py index 679c5f39..2eb8e1f4 100644 --- a/pkgs/sinnixd/sinnixd/projects.py +++ b/pkgs/sinnixd/sinnixd/projects.py @@ -1,5 +1,6 @@ from __future__ import annotations +import argparse import hashlib import json import re @@ -7,7 +8,7 @@ import tomllib from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable, Mapping +from typing import Any, Iterable, Mapping, Sequence from sinnix_mcp import Authority, Lifecycle, OwnerRegistry, OwnerSpec, SinnixRef @@ -195,10 +196,19 @@ class ProjectEnvironment: command: tuple[str, ...] inherit: tuple[str, ...] unset: tuple[str, ...] + preflight: tuple[str, ...] = () def values(self) -> dict[str, str]: return build_environment(inherit=self.inherit, unset=self.unset) + def catalog_row(self, *, agent_capable: bool) -> dict[str, Any]: + return { + "kind": self.kind, + "command": list(self.command), + "preflight": list(self.preflight), + "agent_capable": agent_capable, + } + @dataclass(frozen=True) class ServicePortSlot: @@ -413,6 +423,10 @@ class ProjectAdapter: operations: tuple[ProjectOperation, ...] owner_adapters: tuple[ProjectOwnerAdapter, ...] = () + @property + def agent_capable(self) -> bool: + return self.workspace is not None + def operation(self, name: str) -> ProjectOperation: for operation in self.operations: if operation.name == name: @@ -438,7 +452,8 @@ def catalog_row(self) -> dict[str, Any]: "descriptor": str(self.descriptor), "digest": self.digest, "descriptor_status": self.descriptor_status(), - "workspace": self.workspace.catalog_row() if self.workspace is not None else None, + "environment": self.environment.catalog_row(agent_capable=self.agent_capable), + "workspace": self.workspace.catalog_row() if self.agent_capable else None, "conflicts": self.conflicts.catalog_row(), "operations": [operation.catalog_row() for operation in self.operations], "owner_adapters": [adapter.catalog_row() for adapter in self.owner_adapters], @@ -755,6 +770,11 @@ def load_project_adapter(root: Path) -> ProjectAdapter: command=_string_list(environment.get("command"), "environment.command"), inherit=_optional_string_list(environment.get("inherit"), "environment.inherit"), unset=_optional_string_list(environment.get("unset"), "environment.unset"), + preflight=( + _string_list(environment["preflight"], "environment.preflight") + if "preflight" in environment + else () + ), ) raw_workspace = raw.get("workspace") @@ -1028,3 +1048,42 @@ def owner_adapter(self, operation: str) -> tuple[ProjectAdapter, ProjectOwnerAda if adapter.spec == spec: return project, adapter raise KeyError(f"no project owner adapter for {operation!r}") + + +def validate_agent_environment_descriptors(roots: Iterable[Path]) -> None: + """Require a declared preflight for every checkout-capable project.""" + diagnostics: list[str] = [] + for root in roots: + descriptor = root / ".agentctl" / "project.toml" + project_name = str(root) + try: + raw = tomllib.loads(descriptor.read_text()) + project = raw.get("project") + if isinstance(project, Mapping) and isinstance(project.get("id"), str): + project_name = project["id"] + adapter = load_project_adapter(root) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError, ProjectConfigError) as error: + diagnostics.append(f"{project_name}: invalid descriptor {descriptor}: {error}") + continue + if not adapter.agent_capable: + continue + if not adapter.environment.preflight: + diagnostics.append( + f"{adapter.project_id}: {descriptor} must declare a non-empty environment.preflight" + ) + if diagnostics: + raise ProjectConfigError( + "agent-capable project environment contract failed:\n" + + "\n".join(f"- {diagnostic}" for diagnostic in diagnostics) + ) + + +def project_environment_check_main(arguments: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="sinnixd-project-environment-check") + parser.add_argument("--project-root", type=Path, action="append", required=True) + args = parser.parse_args(arguments) + try: + validate_agent_environment_descriptors(args.project_root) + except ProjectConfigError as error: + parser.error(str(error)) + return 0 diff --git a/pkgs/sinnixd/sinnixd/runner.py b/pkgs/sinnixd/sinnixd/runner.py index ab27bec5..5ff6b3be 100644 --- a/pkgs/sinnixd/sinnixd/runner.py +++ b/pkgs/sinnixd/sinnixd/runner.py @@ -11,6 +11,8 @@ from .limits import maximum_timeout_seconds, valid_timeout_seconds from .projects import ProjectConfigError, revalidate_registered_checkout +AGENT_PREFLIGHT_TIMEOUT_SECONDS = 30 + class RunnerError(ValueError): pass @@ -21,15 +23,28 @@ def _require_strings(value: Mapping[str, Any], fields: Sequence[str]) -> None: raise RunnerError("private typed-job input is invalid") +def _non_empty_argv(value: Any) -> bool: + return isinstance(value, list) and bool(value) and all( + isinstance(item, str) and item for item in value + ) + + def _load(path: Path, job_id: str) -> dict[str, Any]: try: value = json.loads(path.read_text()) except (OSError, json.JSONDecodeError) as error: raise RunnerError("private typed-job input is unavailable") from error - if not isinstance(value, dict) or value.get("schema_version") != 1 or value.get("job_id") != job_id: + if not isinstance(value, dict) or value.get("job_id") != job_id: raise RunnerError("private typed-job identity is invalid") - if value.get("kind") not in {"operator-shell", "attested-agent"}: + kind = value.get("kind") + if kind not in {"operator-shell", "attested-agent"}: raise RunnerError("private typed-job kind is invalid") + if kind == "attested-agent" and value.get("schema_version") != 2: + raise RunnerError( + "stale attested-agent private input schema; retry the agent launch after the environment contract upgrade" + ) + if kind == "operator-shell" and value.get("schema_version") != 1: + raise RunnerError("private typed-job schema is invalid") checkout = value.get("checkout") if not isinstance(checkout, dict) or set(checkout) != { "project_id", @@ -104,13 +119,9 @@ def _exec_shell(value: Mapping[str, Any], checkout: Path) -> None: argv = value.get("argv") environment_command = value.get("environment_command") cwd = value.get("cwd") - if value.get("principal") != "operator" or not isinstance(argv, list) or not argv or any( - not isinstance(item, str) or not item for item in argv - ): + if value.get("principal") != "operator" or not _non_empty_argv(argv): raise RunnerError("operator shell contract is invalid") - if not isinstance(environment_command, list) or not environment_command or any( - not isinstance(item, str) or not item for item in environment_command - ): + if not _non_empty_argv(environment_command): raise RunnerError("operator shell project environment is invalid") if not isinstance(cwd, str): raise RunnerError("operator shell cwd is invalid") @@ -142,7 +153,41 @@ def _run_agent( raise RunnerError("attested agent result artifact is invalid") if not native_runner.is_file() or not os.access(native_runner, os.X_OK): raise RunnerError("native agent runner is unavailable") + environment_command = value.get("environment_command") + environment_preflight = value.get("environment_preflight") + if not _non_empty_argv(environment_command): + raise RunnerError( + "typed agent project environment is missing; declare a non-empty environment.command" + ) + if not _non_empty_argv(environment_preflight): + raise RunnerError( + "typed agent project environment is missing; declare a non-empty environment.preflight" + ) + preflight_command = [*environment_command, *environment_preflight] + try: + preflight = subprocess.run( + preflight_command, + cwd=checkout, + check=False, + timeout=AGENT_PREFLIGHT_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as error: + raise RunnerError( + "agent-preflight-timeout: project environment preflight exceeded " + f"{AGENT_PREFLIGHT_TIMEOUT_SECONDS} seconds before agent implementation; " + "inspect the declared preflight and retry" + ) from error + except OSError as error: + raise RunnerError( + "project environment preflight is unavailable; repair environment.command and retry" + ) from error + if preflight.returncode != 0: + raise RunnerError( + "project environment preflight failed before agent implementation " + f"(exit status {preflight.returncode}); repair the declared environment and retry" + ) command = [ + *environment_command, str(native_runner), "--agent", value["backend"], diff --git a/pkgs/sinnixd/test_service.py b/pkgs/sinnixd/test_service.py index 694e9b06..d8a55b1b 100644 --- a/pkgs/sinnixd/test_service.py +++ b/pkgs/sinnixd/test_service.py @@ -21,6 +21,7 @@ import sinnixd.api as api_module import sinnixd.cli as cli_module import sinnixd.jobs as jobs_module +import sinnixd.runner as runner_module from sinnix_mcp import ErrorCode, OpaquePayload, RequestEnvelope, ResponseEnvelope, SinnixRef, SourceBinding from sinnix_mcp.execution import EnvironmentProfile, ExecutionResult, OwnerExecution @@ -58,8 +59,22 @@ ) from sinnixd.limits import MAX_DECLARED_OPERATION_TIMEOUT_SECONDS from sinnixd.owner_adapters import DeclaredOwnerAdapters, OwnerAdapterError -from sinnixd.projects import ProjectCatalog, ProjectConfigError, RegisteredCheckout, parse_worktree_records -from sinnixd.runner import RunnerError, _exec_shell, _require_environment, _revalidate_checkout, _run_declared +from sinnixd.projects import ( + ProjectCatalog, + ProjectConfigError, + RegisteredCheckout, + parse_worktree_records, + validate_agent_environment_descriptors, +) +from sinnixd.runner import ( + RunnerError, + _load, + _exec_shell, + _require_environment, + _revalidate_checkout, + _run_agent, + _run_declared, +) from sinnixd.service import SinnixdService from sinnixd.tasks import ( BeadsCommandBoundary, @@ -398,6 +413,7 @@ def write_adapter(root: Path, *, project_id: str = "fixture") -> None: [environment] kind = "fixture" command = ["fixture-env", "--command"] +preflight = ["devtools", "status", "--stderr"] inherit = ["HOME"] unset = ["PYTHONPATH"] @@ -4676,7 +4692,7 @@ def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle(tmp_path: P request( "job.agent.start", "systemd-jobs", - {"project_id": "fixture", "checkout_id": "default", "prompt": "operator prompt", "backend": "codex", "model": "fixture", "effort": "high", "credential_profile": "subscription", "timeout_seconds": 60, "result": "last-message"}, + {"project_id": "fixture", "checkout_id": "default", "prompt": "operator prompt", "backend": "claude", "model": "fixture", "effort": "high", "credential_profile": "subscription", "timeout_seconds": 60, "result": "last-message"}, "operator", ) ) @@ -4701,6 +4717,7 @@ def test_typed_shell_and_agent_contracts_share_generic_job_lifecycle(tmp_path: P assert "shell-secret" not in persisted assert "display only" not in persisted assert operator_agent.payload.inline["principal"] == "operator" + assert operator_agent.payload.inline["contract"]["backend"] == "claude" assert len(systemd.started) == 3 assert all(start["unit"].startswith("sinnixd-job-") for start in systemd.started) restarted = GenericJobs(systemd, service.jobs.store, wait_poll_seconds=0.001) @@ -4744,6 +4761,305 @@ def execute(executable: str, argv: list[str], environment: dict[str, str]) -> No assert observed["argv"] == ["nix", "develop", "--command", "python", "-m", "fixture"] +def test_agent_production_route_uses_declared_environment_over_poisoned_ambient_imports( + tmp_path: Path, +) -> None: + """A real typed input gets its PATH and import root only from the declared environment.""" + write_adapter(tmp_path) + environment = tmp_path / "fixture-environment" + environment.write_text( + "#!/bin/sh\n" + "set -eu\n" + "printf 'entered\\n' >> environment.calls\n" + "export PATH=\"$PWD/project-bin:/run/current-system/sw/bin\"\n" + "export PYTHONPATH=\"$PWD\"\n" + "exec \"$@\"\n" + ) + environment.chmod(0o700) + descriptor = tmp_path / ".agentctl" / "project.toml" + descriptor.write_text( + descriptor.read_text().replace( + 'command = ["fixture-env", "--command"]', + f'command = ["{environment}"]', + ) + ) + (tmp_path / "project-bin").mkdir() + (tmp_path / "devtools").mkdir() + (tmp_path / "devtools" / "__init__.py").write_text("") + (tmp_path / "devtools" / "__main__.py").write_text( + "from pathlib import Path\n" + "import sys\n" + "with Path('devtools.calls').open('a') as handle:\n" + " handle.write(' '.join(sys.argv[1:]) + '\\n')\n" + ) + (tmp_path / "fixture_package").mkdir() + (tmp_path / "fixture_package" / "__init__.py").write_text("CHECKOUT = __file__\n") + devtools = tmp_path / "project-bin" / "devtools" + devtools.write_text(f"#!/bin/sh\nexec {sys.executable} -m devtools \"$@\"\n") + devtools.chmod(0o700) + initialize_git_checkout(tmp_path) + + native = tmp_path / "native-runner" + native.write_text( + "#!/bin/sh\n" + "set -eu\n" + "last=\n" + "while [ $# -gt 0 ]; do\n" + " case $1 in --last-file) last=$2; shift 2 ;; *) shift ;; esac\n" + "done\n" + "test \"$(command -v devtools)\" = \"$PWD/project-bin/devtools\"\n" + "devtools status\n" + "devtools test tests/fixture.py::test_noop\n" + "devtools verify --quick\n" + f"{sys.executable} -c 'import fixture_package; assert fixture_package.CHECKOUT'\n" + "printf native-started > native.started\n" + "printf native-result > \"$last\"\n" + ) + native.chmod(0o700) + + systemd = FakeSystemdJobs() + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=native) + response = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + "project_id": "fixture", + "checkout_id": "default", + "prompt": "fixture prompt", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + }, + "agent-control", + ) + ) + assert response.ok and response.payload is not None + job_id = response.payload.inline["job_id"] + launch = systemd.started[0] + private = json.loads((tmp_path / "state" / "inputs" / f"{job_id}.json").read_text()) + assert private["environment_command"] == [str(environment)] + assert private["environment_preflight"] == ["devtools", "status", "--stderr"] + assert private["schema_version"] == 2 + + poisoned = {str(key): str(value) for key, value in launch["environment"].items()} + poisoned["PATH"] = ":".join( + [str(tmp_path / "another-checkout" / "project-bin"), poisoned["PATH"]] + ) + poisoned["PYTHONPATH"] = os.environ.get("PYTHONPATH", "") + mutant = subprocess.run( + [str(native), "--last-file", str(tmp_path / "state" / "results" / "mutant.result")], + cwd=tmp_path, + env=poisoned, + capture_output=True, + text=True, + check=False, + ) + assert mutant.returncode != 0 + assert not (tmp_path / "native.started").exists() + result = subprocess.run( + [ + sys.executable, + "-m", + "sinnixd.runner", + "--input", + str(tmp_path / "state" / "inputs" / f"{job_id}.json"), + "--job-id", + job_id, + "--unit", + f"sinnixd-job-{job_id}.service", + "--native-runner", + str(native), + "--state-root", + str(tmp_path / "state"), + ], + cwd=Path.cwd(), + env=poisoned, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert (tmp_path / "native.started").read_text() == "native-started" + assert (tmp_path / "state" / "results" / f"{job_id}.result").read_text() == "native-result" + assert (tmp_path / "environment.calls").read_text().splitlines() == ["entered", "entered"] + assert (tmp_path / "devtools.calls").read_text().splitlines() == [ + "status --stderr", + "status", + "test tests/fixture.py::test_noop", + "verify --quick", + ] + + +def test_agent_environment_preflight_refuses_missing_declaration_before_launch(tmp_path: Path) -> None: + write_adapter(tmp_path) + descriptor = tmp_path / ".agentctl" / "project.toml" + descriptor.write_text(descriptor.read_text().replace('preflight = ["devtools", "status", "--stderr"]\n', "")) + initialize_git_checkout(tmp_path) + runner = tmp_path / "native-runner" + native_runner(runner) + systemd = FakeSystemdJobs() + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path, systemd), native_runner=runner) + + response = service.dispatch( + request( + "job.agent.start", + "systemd-jobs", + { + "project_id": "fixture", + "checkout_id": "default", + "prompt": "prompt", + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "timeout_seconds": 60, + "result": "last-message", + }, + "agent-control", + ) + ) + assert not response.ok + assert response.error is not None + assert "agent environment preflight" in response.error.message + assert systemd.started == [] + + +def test_agent_environment_preflight_refuses_corrupt_environment_before_native_runner(tmp_path: Path) -> None: + write_adapter(tmp_path) + initialize_git_checkout(tmp_path) + runner = tmp_path / "native-runner" + native_runner(runner) + state = tmp_path / "state" + inputs = state / "inputs" + results = state / "results" + inputs.mkdir(parents=True) + results.mkdir() + prompt = inputs / "fixture.prompt" + prompt.write_text("prompt") + job_id = "11111111-1111-1111-1111-111111111111" + payload = { + "schema_version": 2, + "job_id": job_id, + "kind": "attested-agent", + "principal": "agent-control", + "checkout": ProjectCatalog([tmp_path]).checkout("fixture", "default").to_dict(), + "environment_command": [sys.executable, "-c", "raise SystemExit(17)"], + "environment_preflight": ["fixture-preflight"], + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "prompt_path": str(prompt), + "result_path": str(results / "fixture.result"), + } + + with pytest.raises( + RunnerError, + match="project environment preflight failed before agent implementation.*17", + ): + _run_agent(payload, tmp_path, native_runner=runner, state_root=state) + assert not (results / "fixture.result").exists() + + +def test_agent_environment_preflight_timeout_is_distinct_and_prevents_native_runner( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + state = tmp_path / "state" + inputs = state / "inputs" + results = state / "results" + inputs.mkdir(parents=True) + results.mkdir() + prompt = inputs / "fixture.prompt" + prompt.write_text("prompt") + runner = tmp_path / "native-runner" + native_runner(runner) + payload = { + "schema_version": 2, + "job_id": "11111111-1111-1111-1111-111111111111", + "kind": "attested-agent", + "principal": "agent-control", + "environment_command": ["fixture-environment"], + "environment_preflight": ["status"], + "backend": "codex", + "model": "fixture", + "effort": "high", + "credential_profile": "subscription", + "prompt_path": str(prompt), + "result_path": str(results / "fixture.result"), + } + + calls: list[dict[str, object]] = [] + + def timeout(*_args: object, **kwargs: object) -> None: + calls.append(kwargs) + raise subprocess.TimeoutExpired("fixture-environment", 30) + + monkeypatch.setattr(runner_module.subprocess, "run", timeout) + with pytest.raises(RunnerError, match="agent-preflight-timeout.*30 seconds"): + _run_agent(payload, tmp_path, native_runner=runner, state_root=state) + assert calls == [{"cwd": tmp_path, "check": False, "timeout": 30}] + assert not (results / "fixture.result").exists() + + +def test_pre_upgrade_attested_agent_input_fails_closed_with_stale_schema(tmp_path: Path) -> None: + input_path = tmp_path / "legacy-agent.json" + input_path.write_text( + json.dumps( + { + "schema_version": 1, + "job_id": "11111111-1111-1111-1111-111111111111", + "kind": "attested-agent", + "principal": "agent-control", + } + ) + ) + with pytest.raises(RunnerError, match="stale attested-agent private input schema"): + _load(input_path, "11111111-1111-1111-1111-111111111111") + + +def test_agent_environment_descriptor_audit_reports_each_registered_project(tmp_path: Path) -> None: + fixture = tmp_path / "fixture" + missing_preflight = tmp_path / "missing-preflight" + write_adapter(fixture, project_id="fixture") + write_adapter(missing_preflight, project_id="missing_preflight") + descriptor = fixture / ".agentctl" / "project.toml" + descriptor.write_text(descriptor.read_text().replace('preflight = ["devtools", "status", "--stderr"]\n', "")) + descriptor = missing_preflight / ".agentctl" / "project.toml" + descriptor.write_text(descriptor.read_text().replace('preflight = ["devtools", "status", "--stderr"]\n', "")) + + with pytest.raises(ProjectConfigError, match="agent-capable project environment contract failed") as error: + validate_agent_environment_descriptors([fixture, missing_preflight]) + message = str(error.value) + assert "fixture:" in message + assert "environment.preflight" in message + assert "missing_preflight:" in message + assert message.count("environment.preflight") == 2 + + +def test_project_get_publishes_agent_environment_capability(tmp_path: Path) -> None: + write_adapter(tmp_path) + runner = tmp_path / "native-runner" + native_runner(runner) + service = SinnixdService(ProjectCatalog([tmp_path]), jobs=generic_jobs(tmp_path), native_runner=runner) + + response = service.dispatch( + request("project.get", "project-adapters", {"project_id": "fixture"}, "observer") + ) + + assert response.ok and response.payload is not None + environment = response.payload.inline["environment"] + assert environment == { + "kind": "fixture", + "command": ["fixture-env", "--command"], + "preflight": ["devtools", "status", "--stderr"], + "agent_capable": True, + } + + def test_typed_contracts_refuse_spoofed_principals_checkout_backend_environment_and_results(tmp_path: Path) -> None: write_adapter(tmp_path) initialize_git_checkout(tmp_path) @@ -4847,11 +5163,13 @@ def test_agent_runner_revalidates_checkout_and_writes_a_bounded_result_fixture(t prompt = inputs / "fixture.prompt" prompt.write_text("private fixture prompt") payload = { - "schema_version": 1, + "schema_version": 2, "job_id": "11111111-1111-1111-1111-111111111111", "kind": "attested-agent", "principal": "agent-control", "checkout": checkout.to_dict(), + "environment_command": ["env"], + "environment_preflight": ["true"], "backend": "codex", "model": "fixture", "effort": "high", @@ -4906,11 +5224,13 @@ def test_runner_rejects_forged_sinnix_environment(tmp_path: Path) -> None: input_path.write_text( json.dumps( { - "schema_version": 1, + "schema_version": 2, "job_id": job_id, "kind": "attested-agent", "principal": "agent-control", "checkout": checkout.to_dict(), + "environment_command": ["env"], + "environment_preflight": ["true"], "backend": "codex", "model": "fixture", "effort": "high",