Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agentctl/project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
6 changes: 4 additions & 2 deletions docs/sinnixd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` 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.

Expand Down Expand Up @@ -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.

Expand Down
29 changes: 25 additions & 4 deletions flake/command-registry.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.[]'
Comment on lines +117 to +122

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid the duplicate activation evaluation

Every gated switch, boot, and test-system invocation now performs this separate impure evaluation of the NixOS configuration before nh evaluates the same configuration for the rebuild. Besides duplicating an expensive evaluation and creating another failure/divergence boundary, this directly violates the repository requirement not to preflight a switch with a duplicate evaluation; integrate the contract into the rebuild evaluation or obtain the roots without evaluating nixosConfigurations a second time.

AGENTS.md reference: AGENTS.md:L203-L205

Useful? React with 👍 / 👎.

)
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
Expand Down Expand Up @@ -403,6 +419,7 @@ in
rebuildLock
rebuildContainmentFlags
rebuildDefaultArgs
agentEnvironmentContract
rebuildServicePath
localInputOverrideArgs
avoidRepoCwdForActivation
Expand Down Expand Up @@ -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 \
Expand All @@ -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 \
Expand All @@ -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 \
Expand All @@ -572,6 +592,7 @@ in
${avoidRepoCwdForActivation}
${localInputOverrideArgs}
${rebuildDefaultArgs}
${agentEnvironmentContract}
${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch
_rebuild_status=0
${pkgs.systemd}/bin/systemd-run \
Expand Down Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions flake/dev-shell.nix
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
rebuildServicePath
localInputOverrideArgs
resolveFlakeDir
agentEnvironmentContract
avoidRepoCwdForActivation
switchFallback
;
Expand All @@ -49,6 +50,7 @@
${avoidRepoCwdForActivation}
${localInputOverrideArgs}
${commandRegistry.rebuildDefaultArgs}
${agentEnvironmentContract}
${scriptPkgs.sinnix-preflight}/bin/sinnix-preflight switch

_rebuild_status=0
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions flake/tests.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions flake/tests/command-registry.nix
Original file line number Diff line number Diff line change
@@ -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"
'';
};
}
1 change: 1 addition & 0 deletions pkgs/sinnixd/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 8 additions & 1 deletion pkgs/sinnixd/sinnixd/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Comment on lines +138 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the published agent-capable flag on launch

For a descriptor without [workspace] but with an optional non-empty environment.preflight, the catalog publishes agent_capable = false and the activation audit skips it, yet this check passes and start_agent proceeds because ProjectCatalog.checkout does not require a workspace policy. This makes the advertised capability unreliable and permits launches through a project that the new contract classifies as non-agent-capable; reject not project.agent_capable here as well.

Useful? React with 👍 / 👎.

binding = self._bead_binding(bead_binding, checkout)
job_id = str(uuid4())
prompt_path = self.inputs_root / f"{job_id}.prompt"
Expand All @@ -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,
Expand Down
Loading
Loading